Expand description
cratefield-text-guard: keep the facts when a model rewrites text
(issue #836).
A venture that asks a TextModel to
rewrite, shorten or translate somebody’s text has to be sure the model
did not quietly change what the text says: a figure, a name, a quoted
sentence, a line of code. This crate does the two halves of that check
and nothing else:
Guard::extractfinds the protected spans of a text: numbers, quotations, code, multi-word names, links and hashtags (SpanKind), plus any spans the caller protects itself (ExtraSpan).Guard::verifychecks a rewrite against its original and lists every protected span that went missing or changed, and every number the rewrite introduced (Violation).
use cratefield_text_guard::{SpanKind, ViolationKind, extract, verify};
let original = "Our Q3 revenue grew by 18% to $4.2M, Dana Okafor said.";
let kinds: Vec<_> = extract(original).iter().map(|s| (s.kind, s.text.clone())).collect();
assert_eq!(kinds, [
(SpanKind::Number, "Q3".to_owned()),
(SpanKind::Number, "18%".to_owned()),
(SpanKind::Number, "$4.2M".to_owned()),
(SpanKind::Name, "Dana Okafor".to_owned()),
]);
assert!(verify(original, "Dana Okafor says Q3 revenue rose 18% to $4.2M.").is_ok());
let violations = verify(original, "Dana Okafor says Q3 revenue rose 19% to $4.2M.").unwrap_err();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].kind, ViolationKind::Missing); // 18% is gone
assert_eq!(violations[1].kind, ViolationKind::Introduced); // 19% came from nowhereWhat a caller does with the violations is its own business: retry the model with the failed spans named, refuse the rewrite, or show them.
§The heuristics, on purpose simple
No regex engine, no language model, no dictionary: every rule below is a character scan, so its false positives and negatives can be read off the code. Spans never overlap; when two rules match the same text the earlier rule in this list wins.
- Caller spans (
SpanKind::Custom): byte ranges the caller supplies (Guard::with_extra,Guard::extract_with) for syntax this crate does not know, such as a platform’s mention markup. They come first and beat every built-in rule they overlap; see Caller spans. - Code (
SpanKind::Code): a fenced block (a line opening with three or more backticks or tildes, to the matching closing fence or the end of the text), then inline code (a run of backticks to the next run of the same length, within one paragraph). Must survive verbatim, fences included. - Quotes (
SpanKind::Quote): text between straight double quotes ("…"), curly double quotes (“…”), German low-high quotes („…“) or guillemets («…»), within one paragraph and at mostMAX_QUOTE_CHARSlong. Single quotes are not quotes here:’is also the apostrophe, and telling the two apart needs grammar. What must survive is the quoted words (Span::protected): the quote marks may change style, and a closing,.;or:inside the marks may move, because typographic conventions move them. - Links (
SpanKind::Url):http://orhttps://(any case) followed by a host, or a barewww.followed by a host with a dot in it, starting at a word boundary and running to whitespace or to one of<>"`or a curly quote or guillemet. Sentence punctuation at the end (.,;:!?and closing'or’) is not part of the link, and neither is a closing)or]without a matching opener inside the link:(see https://example.com/a_(b))keepshttps://example.com/a_(b). Must survive verbatim and whole: the rewrite has to contain it as a complete link, sohttps://example.com/ais not found insidehttps://example.com/ab. Digits in a link are part of it, never numbers of their own, and a#in a link is never a hashtag. - Hashtags (
SpanKind::Hashtag):#followed by letters, digits and_(any script), containing at least one letter, where the#is not glued to a letter, digit,_,#or&before it. So#launch,#Q3_resultsand#caféare hashtags, while#1and#123are not (they read as rank and issue numbers, and the digits are protected as a number instead),C#is not, and a Markdown heading (# Title,## Title) is not, because a space follows the marks. A hex colour such as#ff8800is a hashtag by these rules. Must survive verbatim, and a hashtag is not found inside a longer one (#launchin#launch_day). - Numbers (
SpanKind::Number): a token that starts at a word boundary with an optional currency sign and a digit, continues through digits joined by.,:/or-(only between digits, so a full stop after a figure is not part of it), and keeps a%or‰and any letters glued to the end ($4.2M,18%,3rd,10x,2026-10-08). Also one to four capitals glued to digits, with an optional hyphen (Q3,FY2024,H1,GPT-4). Spelled-out numbers (“three million”) are not numbers here. Must survive verbatim. - Names (
SpanKind::Name): two or more capitalised words in a row, separated by single spaces (Dana Okafor,Bank of America: a small set of lowercase particles may sit between capitalised words). A leading function word (The,In,However, …) is dropped first, and a trailing possessive'sis not part of the name. A single capitalised word is never a name: that would lock the first word of every sentence. The flip side: a capitalised word that is not a function word, followed by a capitalised word, is a name, so a sentence opening “Ask Dana” locks “Ask Dana”. Must survive verbatim.
“Survive” means: occur in the rewrite at least once, at a word boundary
(18% is not found inside 118%). The rewrite may reorder spans and
may use one fewer time than the original; it may not drop or alter one.
A number the rewrite contains and the original does not is reported as
ViolationKind::Introduced: a changed figure shows up as one missing
and one introduced. Names, quotes, code, links and hashtags a rewrite
adds are not reported — new wording is the point of a rewrite.
§Caller spans
A platform’s own markup — a mention such as
@[Name](urn:li:organization:123), a template, a placeholder — is
nothing this crate should know about, but a rewrite must keep it, and
the digits inside it must not count as numbers. A caller locks it with
ExtraSpans, either per call (Guard::extract_with,
Guard::verify_with) or with an extractor function that runs on every
text the guard looks at, original and rewrite alike
(Guard::with_extra):
use cratefield_text_guard::{ExtraSpan, Guard, SpanKind};
/// `{{placeholder}}` templates, labelled for the caller's own errors.
fn placeholders(text: &str) -> Vec<ExtraSpan> {
let mut spans = Vec::new();
let mut from = 0;
while let Some(open) = text[from..].find("{{").map(|at| from + at) {
let Some(close) = text[open..].find("}}").map(|at| open + at + 2) else { break };
spans.push(ExtraSpan::new(open..close, "placeholder"));
from = close;
}
spans
}
let guard = Guard::new().with_extra(placeholders);
let spans = guard.extract("Hi {{name_2}}, 18% off");
assert_eq!(spans[0].kind, SpanKind::Custom);
assert_eq!(spans[0].label.as_deref(), Some("placeholder"));
assert_eq!(spans[1].text, "18%"); // the 2 inside the template is not a number
let violations = guard.verify("Hi {{name_2}}", "Hi {{name_3}}").unwrap_err();
assert_eq!(violations.len(), 1); // the template is missing; 3 is not "introduced"Caller spans come back as SpanKind::Custom with the caller’s label
in Span::label. One survives when the rewrite has a caller span with
the same label and the same text: the same characters that the caller’s
rules no longer recognise (an escaped copy of a mention, say) do not
count, so Guard::verify_with needs the rewrite’s caller spans too.
They take precedence over every built-in span they overlap,
including code. A range that is empty, out of bounds, not on a char
boundary, or overlaps an earlier caller span is ignored; per-call spans
count as earlier than the extractor’s.
Structs§
- Extra
Span - A span the caller protects on top of the built-in kinds: a byte range of the text and a label of the caller’s choosing (see Caller spans).
- Guard
- Which span kinds to look for, and the caller’s own extractor if any.
Guard::newlooks for every built-in kind. - Span
- One protected span of a text: its kind, its text, and where it is.
- Violation
- One protected span a rewrite did not keep, or a number it made up.
Enums§
- Span
Kind - What kind of fact a
Spanprotects. - Violation
Kind - How a rewrite broke a protected span.
Constants§
- MAX_
QUOTE_ CHARS - The longest quotation, in characters, that counts as a quote. Longer “quotes” are almost always a stray or unbalanced mark pairing with one paragraphs away, and locking them would freeze most of a text.
Functions§
- extract
Guard::extractwith every kind on.- verify
Guard::verifywith every kind on.
Type Aliases§
- Extractor
- Finds a caller’s own protected spans in a text; see
Guard::with_extra.