global setup and configuration.
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Deterministic dimension-controlled pair generation for the teach calibration game.
|
||||
|
||||
Given a base passage and ONE voice dimension, produce a minimal pair (A, B) where
|
||||
B differs from A along exactly that dimension. The direction of the transform
|
||||
(e.g. contract -> expand vs expand -> contract) is auto-detected from whichever
|
||||
pole the base passage already sits at, so every dimension is reversible: feed it
|
||||
a passage already on one pole and it moves to the other.
|
||||
|
||||
Every transform must preserve the must-preserve constraint tokens extracted by
|
||||
scripts/extract_constraints.py (numbers, dates, names, quotes, units, etc). If a
|
||||
dimension has no expressible transform in the given passage (neither pole's
|
||||
pattern is present, or the only candidate transform would drop a constraint),
|
||||
the command exits 3 with "dimension not expressible in this passage".
|
||||
|
||||
Usage:
|
||||
python3 calibrate_pairs.py generate --base FILE --dimension DIM --seed N
|
||||
python3 calibrate_pairs.py --list-dimensions
|
||||
|
||||
Dimensions: contractions, em_dash, sentence_length, connectives, staccato.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from extract_constraints import extract_constraints # noqa: E402
|
||||
from banned_phrase_scan import scan_for_violations # noqa: E402
|
||||
|
||||
DIMENSIONS = ["contractions", "em_dash", "sentence_length", "connectives", "staccato"]
|
||||
|
||||
# Each dimension has two named poles. transform_applied is "<dimension>:<pole of B>".
|
||||
POLES: dict[str, tuple[str, str]] = {
|
||||
"contractions": ("contracted", "expanded"),
|
||||
"em_dash": ("dashed", "plain"),
|
||||
"sentence_length": ("long", "short"),
|
||||
"connectives": ("plain", "formal"),
|
||||
"staccato": ("staccato", "flowing"),
|
||||
}
|
||||
|
||||
|
||||
class NotExpressible(Exception):
|
||||
"""Raised when a dimension has no applicable transform in the passage."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# contractions: expand <-> contract via a fixed, unambiguous mapping table.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_CONTRACTION_PAIRS = [
|
||||
("do not", "don't"), ("does not", "doesn't"), ("did not", "didn't"),
|
||||
("cannot", "can't"), ("will not", "won't"), ("would not", "wouldn't"),
|
||||
("should not", "shouldn't"), ("could not", "couldn't"), ("must not", "mustn't"),
|
||||
("is not", "isn't"), ("are not", "aren't"), ("was not", "wasn't"), ("were not", "weren't"),
|
||||
("have not", "haven't"), ("has not", "hasn't"), ("had not", "hadn't"),
|
||||
("I am", "I'm"), ("you are", "you're"), ("we are", "we're"), ("they are", "they're"),
|
||||
("I will", "I'll"), ("you will", "you'll"), ("we will", "we'll"), ("they will", "they'll"),
|
||||
("he will", "he'll"), ("she will", "she'll"), ("it will", "it'll"),
|
||||
("I have", "I've"), ("you have", "you've"), ("we have", "we've"), ("they have", "they've"),
|
||||
("let us", "let's"),
|
||||
("it is", "it's"), ("that is", "that's"), ("there is", "there's"),
|
||||
("here is", "here's"), ("who is", "who's"), ("what is", "what's"),
|
||||
]
|
||||
# Pairs whose expanded side always keeps a hard-capitalized pronoun ("I ...").
|
||||
_HARD_CAPITAL = {e for e, c in _CONTRACTION_PAIRS if e.startswith("I ")}
|
||||
|
||||
|
||||
def _case_like(template: str, source_first_char: str) -> str:
|
||||
if source_first_char.isupper():
|
||||
return template[0].upper() + template[1:]
|
||||
return template[0].lower() + template[1:]
|
||||
|
||||
|
||||
# Words safe to lowercase when a sentence is folded mid-clause. A capitalized
|
||||
# word NOT in this set is assumed to be a proper noun (constraint) and is left
|
||||
# alone -- lowercasing "Priya" into a joined clause would corrupt a name.
|
||||
_LOWERABLE_JOINERS = {
|
||||
"the", "it", "she", "he", "they", "this", "that", "there", "i", "we", "you",
|
||||
"who", "what", "when", "where", "why", "how", "a", "an", "and", "but", "so",
|
||||
"yet", "or", "nobody", "everybody", "everyone", "someone", "something",
|
||||
"nothing", "no", "her", "his", "its", "their", "our", "your",
|
||||
}
|
||||
|
||||
|
||||
def _lower_first_word(s: str) -> str:
|
||||
"""Lowercase the leading word only if it is a common function word/pronoun,
|
||||
never a token that looks like a proper noun a constraint might depend on."""
|
||||
m = re.match(r"[A-Za-z']+", s)
|
||||
if not m:
|
||||
return s
|
||||
word = m.group(0)
|
||||
if word.lower() in _LOWERABLE_JOINERS:
|
||||
return word.lower() + s[len(word):]
|
||||
return s
|
||||
|
||||
|
||||
_WORD_BEFORE_RE = re.compile(r"([A-Za-z']+)\s*$")
|
||||
_WORD_AFTER_RE = re.compile(r"^\s*([A-Za-z']+)")
|
||||
|
||||
|
||||
def _is_title_case(word: str) -> bool:
|
||||
return bool(word) and word[0].isupper() and word[1:].islower()
|
||||
|
||||
|
||||
def _in_capitalized_span(m: "re.Match[str]") -> bool:
|
||||
"""True if this contraction/expansion match sits inside a capitalized
|
||||
multi-word span (e.g. "Venue Can't Stop") that looks like a proper noun,
|
||||
rather than an ordinary sentence-level contraction.
|
||||
|
||||
A contraction rewrite is only skipped when the match ITSELF is
|
||||
capitalized (so an ordinary sentence-initial "Can't you..." is untouched)
|
||||
AND an immediately adjacent word is also Title Case, forming a >= 2-word
|
||||
capitalized run. "Maria Chen can't attend" is not skipped (the match is
|
||||
lowercase); "the Venue Can't Stop" is skipped (both "Venue" and "Can't"
|
||||
are capitalized and adjacent).
|
||||
"""
|
||||
matched = m.group(0)
|
||||
if not matched[0].isupper():
|
||||
return False
|
||||
text = m.string
|
||||
before_m = _WORD_BEFORE_RE.search(text[: m.start()])
|
||||
after_m = _WORD_AFTER_RE.match(text[m.end() :])
|
||||
before_word = before_m.group(1) if before_m else ""
|
||||
after_word = after_m.group(1) if after_m else ""
|
||||
return _is_title_case(before_word) or _is_title_case(after_word)
|
||||
|
||||
|
||||
def _contraction_repl(replacement: str, hard: bool):
|
||||
"""One replacement closure shared by both contraction directions (expand
|
||||
and contract) and both casing modes (hard-capital literal vs case-matched).
|
||||
"""
|
||||
def repl(m):
|
||||
if _in_capitalized_span(m):
|
||||
return m.group(0)
|
||||
return replacement if hard else _case_like(replacement, m.group(0)[0])
|
||||
return repl
|
||||
|
||||
|
||||
def _apply_contractions(text: str) -> tuple[str, str]:
|
||||
contract_hits = []
|
||||
expand_hits = []
|
||||
for expanded, contracted in _CONTRACTION_PAIRS:
|
||||
if expanded in _HARD_CAPITAL:
|
||||
# "I am"/"I'll"/... always literally capitalized; case-sensitive match.
|
||||
if re.search(re.escape(contracted), text):
|
||||
contract_hits.append((expanded, contracted))
|
||||
if re.search(re.escape(expanded), text):
|
||||
expand_hits.append((expanded, contracted))
|
||||
else:
|
||||
if re.search(r"\b" + re.escape(contracted) + r"\b", text, re.IGNORECASE):
|
||||
contract_hits.append((expanded, contracted))
|
||||
if re.search(r"\b" + re.escape(expanded) + r"\b", text, re.IGNORECASE):
|
||||
expand_hits.append((expanded, contracted))
|
||||
|
||||
if contract_hits:
|
||||
out = text
|
||||
for expanded, contracted in contract_hits:
|
||||
hard = expanded in _HARD_CAPITAL
|
||||
pattern = re.escape(contracted) if hard else r"\b" + re.escape(contracted) + r"\b"
|
||||
flags = 0 if hard else re.IGNORECASE
|
||||
out = re.sub(pattern, _contraction_repl(expanded, hard), out, flags=flags)
|
||||
return out, "expanded"
|
||||
|
||||
if expand_hits:
|
||||
out = text
|
||||
for expanded, contracted in expand_hits:
|
||||
hard = expanded in _HARD_CAPITAL
|
||||
pattern = re.escape(expanded) if hard else r"\b" + re.escape(expanded) + r"\b"
|
||||
flags = 0 if hard else re.IGNORECASE
|
||||
out = re.sub(pattern, _contraction_repl(contracted, hard), out, flags=flags)
|
||||
return out, "contracted"
|
||||
|
||||
raise NotExpressible("no contraction or expandable phrase found")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# em_dash: paired em-dash parentheticals <-> paired-comma parentheticals ONLY.
|
||||
#
|
||||
# Only the paired-dash<->paired-comma path is expressible for this dimension.
|
||||
# A lone joiner dash (" — nobody objected") has no comma-pair equivalent that
|
||||
# preserves sentence count: turning it into a period (the old behavior) split
|
||||
# one sentence into two, silently changing sentence count between A and B.
|
||||
# Passages with only a lone dash (no paired construction) are declined rather
|
||||
# than forced through a transform that shifts sentence count.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_PAIRED_DASH_RE = re.compile(r"\s—\s(.+?)\s—\s")
|
||||
_PAIRED_COMMA_RE = re.compile(r",\s+(.+?),\s+")
|
||||
|
||||
|
||||
def _apply_em_dash(text: str) -> tuple[str, str]:
|
||||
if _PAIRED_DASH_RE.search(text):
|
||||
out = _PAIRED_DASH_RE.sub(lambda m: ", " + m.group(1) + ", ", text)
|
||||
if "—" in out:
|
||||
# A lone dash survived alongside the paired one -- the restricted
|
||||
# paired-dash<->comma path can't fully express this passage
|
||||
# without also touching the lone dash (and changing sentence
|
||||
# count), so decline rather than ship a half-converted pair.
|
||||
raise NotExpressible(
|
||||
"passage mixes a paired em dash with a lone em dash; "
|
||||
"the paired-dash<->comma path can't resolve the lone dash "
|
||||
"without changing sentence count"
|
||||
)
|
||||
return out, "plain"
|
||||
|
||||
if "—" in text:
|
||||
raise NotExpressible(
|
||||
"only a lone em dash found; the em_dash dimension is restricted "
|
||||
"to the paired-dash<->comma path so sentence count stays stable"
|
||||
)
|
||||
|
||||
if _PAIRED_COMMA_RE.search(text):
|
||||
out = _PAIRED_COMMA_RE.sub(lambda m: " — " + m.group(1) + " — ", text)
|
||||
return out, "dashed"
|
||||
|
||||
raise NotExpressible("no paired em dash or comma-bounded parenthetical found")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# sentence_length: split at coordinators <-> join short adjacent sentences.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_COORD_RE = re.compile(r",\s+(and|but|or|so|yet)\s+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _apply_sentence_length(text: str) -> tuple[str, str]:
|
||||
sentences = re.findall(r"[^.!?]+[.!?]+", text)
|
||||
|
||||
for sent in sentences:
|
||||
for m in _COORD_RE.finditer(sent):
|
||||
before = sent[:m.start()]
|
||||
after = sent[m.end():]
|
||||
if len(before.split()) >= 3 and len(after.split()) >= 3:
|
||||
new_sent = before.rstrip() + ". " + m.group(1).capitalize() + " " + after
|
||||
out = text.replace(sent, new_sent, 1)
|
||||
return re.sub(r"\s+", " ", out).strip(), "short"
|
||||
|
||||
for i in range(len(sentences) - 1):
|
||||
a_words = sentences[i].strip().split()
|
||||
b_words = sentences[i + 1].strip().split()
|
||||
if len(a_words) <= 8 and len(b_words) <= 8:
|
||||
first_no_period = re.sub(r"[.!?]+\s*$", "", sentences[i].strip())
|
||||
second = sentences[i + 1].strip()
|
||||
second_body = re.sub(r"[.!?]+\s*$", "", second)
|
||||
end_punct = second[len(second_body):].strip() or "."
|
||||
joined = first_no_period + ", and " + _lower_first_word(second_body) + end_punct
|
||||
prefix = "".join(sentences[:i])
|
||||
suffix = "".join(sentences[i + 2:])
|
||||
out = (prefix + " " + joined + " " + suffix).strip()
|
||||
return re.sub(r"\s+", " ", out), "long"
|
||||
|
||||
raise NotExpressible("no coordinator split site or joinable short sentences found")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# connectives: formal <-> plain via a fixed table.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_FORMAL_TO_PLAIN = {
|
||||
"however": "But", "additionally": "Also", "therefore": "So",
|
||||
"furthermore": "Also", "moreover": "Also", "nevertheless": "Still",
|
||||
"consequently": "So", "nonetheless": "Still", "subsequently": "Then",
|
||||
"thus": "So",
|
||||
}
|
||||
_PLAIN_TO_FORMAL = {
|
||||
"but": "However", "also": "Additionally", "so": "Therefore",
|
||||
"still": "Nevertheless", "then": "Subsequently",
|
||||
}
|
||||
_FORMAL_RE = re.compile(
|
||||
r"(?:^|(?<=[.!?]\s))(" + "|".join(_FORMAL_TO_PLAIN) + r"),?\s+", re.IGNORECASE
|
||||
)
|
||||
_PLAIN_RE = re.compile(
|
||||
r"(?:^|(?<=[.!?]\s))(" + "|".join(_PLAIN_TO_FORMAL) + r"),?\s+", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _apply_connectives(text: str) -> tuple[str, str]:
|
||||
if _FORMAL_RE.search(text):
|
||||
def repl(m):
|
||||
# No comma after the plain form: "But " reads as ordinary speech;
|
||||
# "But, " is stilted, and "So, " specifically trips the scanner's
|
||||
# filler_opener structural pattern. Dropping the comma across the
|
||||
# board keeps every plain form clean of that tell.
|
||||
return _FORMAL_TO_PLAIN[m.group(1).lower()] + " "
|
||||
out = _FORMAL_RE.sub(repl, text)
|
||||
return out, "plain"
|
||||
|
||||
if _PLAIN_RE.search(text):
|
||||
def repl2(m):
|
||||
return _PLAIN_TO_FORMAL[m.group(1).lower()] + ", "
|
||||
out = _PLAIN_RE.sub(repl2, text)
|
||||
return out, "formal"
|
||||
|
||||
raise NotExpressible("no formal or plain connective found")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# staccato: fragment runs <-> flowing clauses.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_LEADING_JOINER_RE = re.compile(
|
||||
r"^(?:because|although|while|since|if|when|and|but|so|yet|or)\s+", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _apply_staccato(text: str, rng: random.Random) -> tuple[str, str]:
|
||||
sentences = re.findall(r"[^.!?]+[.!?]+", text)
|
||||
|
||||
# Reverse direction: a run of >= 3 consecutive short (<=6 word) sentences -> flow.
|
||||
runs = []
|
||||
run_start = None
|
||||
for i, sent in enumerate(sentences):
|
||||
words = sent.strip().split()
|
||||
if len(words) <= 6:
|
||||
if run_start is None:
|
||||
run_start = i
|
||||
else:
|
||||
if run_start is not None and i - run_start >= 3:
|
||||
runs.append((run_start, i))
|
||||
run_start = None
|
||||
if run_start is not None and len(sentences) - run_start >= 3:
|
||||
runs.append((run_start, len(sentences)))
|
||||
|
||||
if runs:
|
||||
start, end = runs[rng.randrange(len(runs))] if len(runs) > 1 else runs[0]
|
||||
parts = []
|
||||
for idx in range(start, end):
|
||||
body = re.sub(r"[.!?]+\s*$", "", sentences[idx].strip())
|
||||
parts.append(body)
|
||||
joined_parts = []
|
||||
for i, p in enumerate(parts):
|
||||
if i == 0:
|
||||
joined_parts.append(p)
|
||||
elif i == len(parts) - 1:
|
||||
joined_parts.append("and " + _lower_first_word(p))
|
||||
else:
|
||||
joined_parts.append(_lower_first_word(p))
|
||||
joined = ", ".join(joined_parts) + "."
|
||||
prefix = "".join(sentences[:start])
|
||||
suffix = "".join(sentences[end:])
|
||||
out = (prefix + " " + joined + " " + suffix).strip()
|
||||
return re.sub(r"\s+", " ", out), "flowing"
|
||||
|
||||
# Forward direction: the sentence with the most commas (>= 2) fragments.
|
||||
best_idx, best_commas = None, 1
|
||||
for i, sent in enumerate(sentences):
|
||||
commas = sent.count(",")
|
||||
if commas > best_commas:
|
||||
best_idx, best_commas = i, commas
|
||||
|
||||
if best_idx is not None:
|
||||
sent = sentences[best_idx]
|
||||
body = re.sub(r"[.!?]+\s*$", "", sent.strip())
|
||||
end_punct = sent.strip()[len(body):].strip() or "."
|
||||
fragments = [f.strip() for f in body.split(",")]
|
||||
cleaned = []
|
||||
for frag in fragments:
|
||||
frag = _LEADING_JOINER_RE.sub("", frag).strip()
|
||||
if not frag:
|
||||
continue
|
||||
frag = frag[0].upper() + frag[1:]
|
||||
cleaned.append(frag)
|
||||
if len(cleaned) < 3:
|
||||
raise NotExpressible("comma split did not yield a fragment run")
|
||||
cleaned[-1] = cleaned[-1] + end_punct if not cleaned[-1].endswith((".", "!", "?")) else cleaned[-1]
|
||||
new_sent = " ".join(
|
||||
f if f.endswith((".", "!", "?")) else f + "." for f in cleaned
|
||||
)
|
||||
out = text.replace(sent, new_sent, 1)
|
||||
return re.sub(r"\s+", " ", out).strip(), "staccato"
|
||||
|
||||
raise NotExpressible("no fragment run or multi-comma sentence found")
|
||||
|
||||
|
||||
_APPLY = {
|
||||
"contractions": lambda text, rng: _apply_contractions(text),
|
||||
"em_dash": lambda text, rng: _apply_em_dash(text),
|
||||
"sentence_length": lambda text, rng: _apply_sentence_length(text),
|
||||
"connectives": lambda text, rng: _apply_connectives(text),
|
||||
"staccato": _apply_staccato,
|
||||
}
|
||||
|
||||
_EXAMPLES = {
|
||||
"contractions": {
|
||||
"description": "Expand <-> contract via a fixed, unambiguous mapping table "
|
||||
"(do not <-> don't, I am <-> I'm, it is <-> it's, ...).",
|
||||
"a": "The rollout is not finished, and I am not confident it will ship Friday.",
|
||||
"b": "The rollout isn't finished, and I'm not confident it'll ship Friday.",
|
||||
},
|
||||
"em_dash": {
|
||||
"description": "Paired em-dash parentheticals <-> paired-comma parentheticals only "
|
||||
"(a lone joiner dash has no comma-pair equivalent that preserves "
|
||||
"sentence count, so it is declined rather than converted).",
|
||||
"a": "The plan — untested and rushed — still shipped on time.",
|
||||
"b": "The plan, untested and rushed, still shipped on time.",
|
||||
},
|
||||
"sentence_length": {
|
||||
"description": "Split at a coordinator <-> join two short adjacent sentences.",
|
||||
"a": "The team shipped the fix, and the client renewed the contract.",
|
||||
"b": "The team shipped the fix. And the client renewed the contract.",
|
||||
},
|
||||
"connectives": {
|
||||
"description": "Formal <-> plain connective swap via a fixed table "
|
||||
"(However -> But, Additionally -> Also, Therefore -> So, ...). "
|
||||
"Plain forms drop the comma after the connective (\"But \", not "
|
||||
"\"But, \") so they read as ordinary speech, not a filler-opener tell.",
|
||||
"a": "However, the numbers slipped in March.",
|
||||
"b": "But the numbers slipped in March.",
|
||||
},
|
||||
"staccato": {
|
||||
"description": "Fragment runs <-> flowing clauses.",
|
||||
"a": "Because the deploy failed, and the on-call missed the page, the team lost an hour.",
|
||||
"b": "The deploy failed. The on-call missed the page. The team lost an hour.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _is_word_char(ch: str) -> bool:
|
||||
return ch.isalnum()
|
||||
|
||||
|
||||
def _has_whole_occurrence(value: str, text: str) -> bool:
|
||||
"""True if `value` occurs in `text` as a standalone span rather than as a
|
||||
substring straddling the edge of a longer, different word.
|
||||
|
||||
Plain substring containment is not enough: expanding "Can't" to "Cannot"
|
||||
still contains the literal characters "Can", so a naive `"Can" in
|
||||
"Cannot"` check reports a corrupted proper noun as "preserved". At each
|
||||
edge of a candidate match, this only counts as a clash (disqualifying the
|
||||
match) when BOTH the value's own boundary character and the adjacent text
|
||||
character are alphanumeric -- i.e. when `value` could be glued onto a
|
||||
longer token. Values that start/end with punctuation (currency signs,
|
||||
quotes) never clash on that edge, since punctuation can't be silently
|
||||
swallowed into a bigger word the way a letter/digit can.
|
||||
"""
|
||||
if not value:
|
||||
return False
|
||||
start = 0
|
||||
while True:
|
||||
idx = text.find(value, start)
|
||||
if idx == -1:
|
||||
return False
|
||||
left_clash = idx > 0 and _is_word_char(text[idx - 1]) and _is_word_char(value[0])
|
||||
end = idx + len(value)
|
||||
right_clash = end < len(text) and _is_word_char(text[end]) and _is_word_char(value[-1])
|
||||
if not left_clash and not right_clash:
|
||||
return True
|
||||
start = idx + 1
|
||||
|
||||
|
||||
def _verify_constraints_preserved(base_text: str, transformed_text: str) -> list[str]:
|
||||
"""Return constraint values from base_text missing from transformed_text.
|
||||
|
||||
Uses whole-occurrence (token-boundary) comparison rather than plain
|
||||
substring containment -- see `_has_whole_occurrence` for why a substring
|
||||
check is unsafe here.
|
||||
"""
|
||||
missing = []
|
||||
for c in extract_constraints(base_text):
|
||||
value = c["value"]
|
||||
if _has_whole_occurrence(value, transformed_text):
|
||||
continue
|
||||
# Allow whitespace-normalized matches (transforms may collapse spacing).
|
||||
normalized_value = re.sub(r"\s+", " ", value)
|
||||
normalized_text = re.sub(r"\s+", " ", transformed_text)
|
||||
if normalized_value != value and _has_whole_occurrence(normalized_value, normalized_text):
|
||||
continue
|
||||
missing.append(value)
|
||||
return missing
|
||||
|
||||
|
||||
def _scan_flags(text: str) -> list[str]:
|
||||
"""Category names banned_phrase_scan raises on `text`, deduped and sorted.
|
||||
|
||||
Empty when the text scans clean. Generated B-variants can legitimately
|
||||
trip the scanner (e.g. a staccato pole reads as anti_slop_register; see
|
||||
references/calibrate.md "Voice overrides defaults") -- that is surfaced
|
||||
here, not treated as a reason to decline the pair.
|
||||
"""
|
||||
categories = {v["category"] for v in scan_for_violations(text)}
|
||||
return sorted(categories)
|
||||
|
||||
|
||||
def generate_pair(base_text: str, dimension: str, seed: int) -> dict:
|
||||
if dimension not in DIMENSIONS:
|
||||
raise ValueError(f"unknown dimension: {dimension}")
|
||||
|
||||
rng = random.Random(seed)
|
||||
transformed, pole = _APPLY[dimension](base_text, rng)
|
||||
|
||||
if transformed.strip() == base_text.strip():
|
||||
raise NotExpressible("transform produced no change")
|
||||
|
||||
missing = _verify_constraints_preserved(base_text, transformed)
|
||||
if missing:
|
||||
raise NotExpressible(
|
||||
"transform would drop must-preserve constraint(s): " + ", ".join(missing)
|
||||
)
|
||||
|
||||
pair_id = hashlib.sha256(
|
||||
f"{base_text}|{dimension}|{seed}".encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
return {
|
||||
"pair_id": pair_id,
|
||||
"dimension": dimension,
|
||||
"a_text": base_text,
|
||||
"b_text": transformed,
|
||||
"transform_applied": f"{dimension}:{pole}",
|
||||
"a_flags": _scan_flags(base_text),
|
||||
"b_flags": _scan_flags(transformed),
|
||||
}
|
||||
|
||||
|
||||
def list_dimensions() -> dict:
|
||||
return {
|
||||
dim: {
|
||||
"poles": list(POLES[dim]),
|
||||
"description": _EXAMPLES[dim]["description"],
|
||||
"example": {"a": _EXAMPLES[dim]["a"], "b": _EXAMPLES[dim]["b"]},
|
||||
}
|
||||
for dim in DIMENSIONS
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
gen = sub.add_parser("generate", help="generate one dimension-controlled pair")
|
||||
gen.add_argument("--base", required=True, help="path to base passage file")
|
||||
gen.add_argument("--dimension", required=True, choices=DIMENSIONS)
|
||||
gen.add_argument("--seed", type=int, default=0)
|
||||
|
||||
parser.add_argument("--list-dimensions", action="store_true")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
if args.list_dimensions:
|
||||
print(json.dumps(list_dimensions(), indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
if args.command != "generate":
|
||||
print(json.dumps({"error": "no command given; use generate or --list-dimensions"}))
|
||||
return 1
|
||||
|
||||
try:
|
||||
base_text = Path(args.base).read_text()
|
||||
except OSError as e:
|
||||
print(json.dumps({"error": f"could not read base file: {e}"}))
|
||||
return 2
|
||||
|
||||
try:
|
||||
pair = generate_pair(base_text, args.dimension, args.seed)
|
||||
except NotExpressible as e:
|
||||
print(json.dumps({
|
||||
"error": "dimension not expressible in this passage",
|
||||
"dimension": args.dimension,
|
||||
"detail": str(e),
|
||||
}))
|
||||
return 3
|
||||
|
||||
print(json.dumps(pair, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user