From c319014f3ced9ae33610f0540636b795feec927a Mon Sep 17 00:00:00 2001 From: Parshkov Date: Mon, 7 Sep 2026 01:28:56 -0700 Subject: [PATCH] Stop presenting grammar as somebody's reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card said «что --causes--> ребёнок». The owner read it on his own thought and called it nonsense, correctly. Two mistakes met. The Russian cue table was written by walking the English one and translating, and makes|made|make became делает|делают|сделал. That is a false friend: English 'makes' is causal only in 'makes X happen', while Russian «делает» is the ordinary verb 'does', so «момент к тому, что делает ребёнок» was read as a causal claim. And nothing stopped a bare complementiser from becoming a node once a cue landed beside it -- PRONOUN_ONLY marks a label resolvable to an antecedent, and with no antecedent the label survived. The false friend is gone and an argument made only of function words no longer grounds an endpoint, so the caller abstains instead of minting a node out of grammar. NEVER_A_NODE is Cyrillic-only on purpose: English arguments cannot reach it, and the extraction gate is still byte-identical on all twelve figures. Separately, the composer silently swallowed every keystroke past 4000 characters, so a 7300-character idea pasted in arrived as a 4000-character one with nobody told which half was read -- and the half dropped was the substantive one. The limit is now stated, the submit is blocked while over it, and the person chooses what to cut. Recall on real Russian prose remains poor and ADR-0008 now says so rather than implying otherwise. Co-Authored-By: Claude Opus 5 --- demo/ui/app.css | 4 ++ demo/ui/main.mjs | 13 ++++++- demo/ui/strings.mjs | 1 + .../ADR-0008-russian-prose-extraction.md | 29 ++++++++++++++ src/extraction/cue.py | 25 +++++++++++- tests/test_russian_prose_extraction.py | 39 +++++++++++++++++++ 6 files changed, 107 insertions(+), 4 deletions(-) diff --git a/demo/ui/app.css b/demo/ui/app.css index f19f612..50b9b33 100644 --- a/demo/ui/app.css +++ b/demo/ui/app.css @@ -602,3 +602,7 @@ progress::-moz-progress-bar { background: var(--accent); } .radar__series--you .radar__poly { fill: none; stroke: var(--ink-3); stroke-dasharray: 4 4; stroke-width: 1.5; } .radar__series--you .radar__dot { display: none; } .drawer .radar { max-height: 320px; } + +/* Over the length the server can read. Said in the page's own error colour, + because the previous behaviour was to enforce it by silence. */ +.hint--warn { color: var(--rust); } diff --git a/demo/ui/main.mjs b/demo/ui/main.mjs index f9f0e1b..f0441e4 100644 --- a/demo/ui/main.mjs +++ b/demo/ui/main.mjs @@ -545,6 +545,8 @@ function thoughtCard(row) { // which is right for a model and unusable for a person, who was shown it // verbatim on the page. The page has always had a sentence for this; it was // only ever reached when the server said nothing at all. +const COMPOSE_LIMIT = 4000; + function readableFailure(message) { const said = String(message || ""); return /no shareable structure could be extracted/i.test(said) || !said @@ -651,7 +653,12 @@ function composer() { const c = ui.composer; const box = el("section", {class: "panel composer", "aria-label": t("thoughts.new")}); if (c.step === "write") { - const area = el("textarea", {id: "compose-text", rows: 6, maxLength: 4000, placeholder: t("thoughts.composer.placeholder"), value: c.text, oninput: (e) => { c.text = e.target.value; }}); + const area = el("textarea", {id: "compose-text", rows: 6, placeholder: t("thoughts.composer.placeholder"), value: c.text, oninput: (e) => { c.text = e.target.value; render(); }}); + // The server takes 4000 characters. `maxLength` used to enforce that by + // silently swallowing every keystroke past it, so a long idea pasted in + // arrived truncated and nobody was told which half was read. Say it + // instead, and let the person choose what to cut. + const over = c.text.length - COMPOSE_LIMIT; const status = el("p", {class: `status ${c.error ? "status--error" : ""}`, role: "status"}, c.busy ? t("thoughts.composer.reading") : (c.error || "")); const place = el("div", {class: "place-row"}); const placeToggle = el("input", {type: "checkbox", id: "compose-place", checked: !!c.place}); @@ -668,6 +675,7 @@ function composer() { el("p", {class: "hint"}, t("thoughts.place.hint", {lat: c.place.lat, lon: c.place.lon}))); const submit = button(t("thoughts.composer.extract"), async () => { if (!c.text.trim()) { area.focus(); return; } + if (c.text.length > COMPOSE_LIMIT) { area.focus(); return; } c.busy = true; c.error = ""; render(); try { const where = c.place && (ui.drafts["compose-city"] || "").trim() @@ -681,7 +689,8 @@ function composer() { } catch (error) { c.error = error.message; } c.busy = false; render(); }, {variant: "btn--primary", disabled: c.busy}); - box.append(el("label", {for: "compose-text", class: "label"}, t("thoughts.composer.label")), area, el("p", {class: "hint"}, t("thoughts.composer.hint")), place, + box.append(el("label", {for: "compose-text", class: "label"}, t("thoughts.composer.label")), area, el("p", {class: over > 0 ? "hint hint--warn" : "hint"}, + over > 0 ? t("thoughts.composer.too_long", {over: String(over)}) : t("thoughts.composer.hint")), place, el("div", {class: "row"}, [submit, button(t("cancel"), () => { ui.composer = null; render(); }, {variant: "btn--quiet"})]), status); return box; } diff --git a/demo/ui/strings.mjs b/demo/ui/strings.mjs index 1a143fb..c07342d 100644 --- a/demo/ui/strings.mjs +++ b/demo/ui/strings.mjs @@ -90,6 +90,7 @@ const STRINGS = { "thoughts.composer.label": "What are you working on, and what is hard about it?", "thoughts.composer.placeholder": "Say what causes what, what prevents what, what requires what. For example: “A partial outage causes synchronized client retries. The retries cause request amplification. Jittered backoff prevents the amplification.”", "thoughts.composer.hint": "Your text is not kept. Only the structure it contains becomes visible, and you will see it before anyone else can.", + "thoughts.composer.too_long": "This is {over} characters over the 4000 this can read. Nothing is cut silently — shorten it, or share the part that carries the reasoning.", "thoughts.composer.extract": "Show what would be shared", "thoughts.composer.reading": "Reading the structure…", "thoughts.composer.preview": "This is all anyone will ever see", diff --git a/docs/decisions/ADR-0008-russian-prose-extraction.md b/docs/decisions/ADR-0008-russian-prose-extraction.md index 15008c9..85bde2b 100644 --- a/docs/decisions/ADR-0008-russian-prose-extraction.md +++ b/docs/decisions/ADR-0008-russian-prose-extraction.md @@ -87,6 +87,35 @@ rather than a Russian-only fault discovered again later. other language is still English-only, and the same four subsystems would need the same treatment. +## Corrected the same day, by the owner reading his own card + +The card said **«что --causes--> ребёнок»** — a grammatical particle presented +to a person as their own reasoning. + +Two mistakes met. The Russian table was written by walking the English one and +translating, and `makes|made|make` became «делает|делают|сделал». That is a +false friend: English "makes" is causal only in "makes X happen", while Russian +«делает» is the ordinary verb "does". So «момент к тому, что делает ребёнок» +("torque added to what the child does") was read as a causal claim. And nothing +stopped a bare complementiser from becoming a node once a cue landed beside it +— `PRONOUN_ONLY` marks a label *resolvable* to an antecedent, and when there is +none the label survived. + +Fixed by dropping the false friend and by refusing to ground an argument made +only of function words (`NEVER_A_NODE`, Cyrillic-only so English cannot reach +it; the gate is still byte-identical). A wrong relation is worse than a missing +one here: the entire promise is that what is shown is the person's own +reasoning, so grammar rendered as a causal claim is the worst failure this +extractor has. + +**Recall on that real document is poor and this ADR should not pretend +otherwise.** A ~7300-character design document containing roughly fifteen +explicit causal claims yielded three relations, one of which reads the opening +pleasantry rather than the idea. The misses are ordinary Russian connectives +not yet in the table («иначе», «помогает») and cross-sentence reference, which +English does not do either. The next honest step is the Russian gold set named +below, not more patterns added by eye. + ## What would falsify this A Russian corpus where extraction quality is materially worse than English on diff --git a/src/extraction/cue.py b/src/extraction/cue.py index 91adb0b..992c3b4 100644 --- a/src/extraction/cue.py +++ b/src/extraction/cue.py @@ -105,7 +105,7 @@ (r"привести к|приводить к|вести к|приводит к|приводят к|привело к|привела к|привели к|ведёт к|ведет к|ведут к|вело к|влечёт за собой|влечет за собой|влекут за собой|оборачивается|выливается в|вылилось в", "causes", "fwd", 0.86), (r"вызвать|вызывать|породить|порождать|спровоцировать|создать|создавать|вызывает|вызывают|вызвал|вызвала|вызвало|вызвали|порождает|порождают|породил|породило|провоцирует|провоцируют|создаёт|создает|создают|создал|создало", "causes", "fwd", 0.88), (r"увеличить|увеличивать|повысить|повышать|усилить|усиливать|ускорить|ускорять|ухудшить|ухудшать|усугубить|увеличивает|увеличивают|повышает|повышают|усиливает|усиливают|усугубляет|усугубляют|обостряет|обостряют|ускоряет|ускоряют|раздувает|подстёгивает|подстегивает|ухудшает|ухудшают", "causes", "fwd", 0.74), - (r"делает|делают|сделал|сделало|превращает|превращают|превратил|превратило", "causes", "fwd", 0.6), + (r"превращает|превращают|превратил|превратило", "causes", "fwd", 0.6), # clause-level consequence markers: the previous clause causes this one (r"поэтому|следовательно|таким образом|в итоге|в результате чего|значит|стало быть|и тогда|отсюда", "causes", "fwd", 0.7), # purpose reads as the English "so that": the left clause is done to bring @@ -198,7 +198,12 @@ ) PRONOUN_ONLY = frozenset("this that it which they these those such he she we one " "это эта этот эти то тот та те он она оно они мы вы я такой такая такое " - "который которая которое которые".split()) + "который которая которое которые " + # Function words that are not things: a cue landing + # beside one used to mint it as a node, which is how + # «что делает ребёнок» became «что --causes--> ребёнок». + "что чего чему чем кто кого кому как где куда когда зачем почему " + "том тому тем ком".split()) NEGATORS = re.compile( r"(?:\bdo\s+not|\bdoes\s+not|\bdid\s+not|\bcannot|\bcan(?:no)?'?t|\bwill\s+not|\bwon'?t|\bwould\s+not|" r"\bwouldn'?t|\bnever|\bnot|\bno\s+longer|\bfails?\s+to|\bfailed\s+to|\bdoesn'?t|\bdon'?t|\bdidn'?t|\bisn'?t|" @@ -306,6 +311,17 @@ def _clause_bounds(text: str, s_start: int, s_end: int, cue_start: int, cue_end: return l0, cue_start, cue_end, r1 +# Words that are never a thing being reasoned about. `PRONOUN_ONLY` marks a +# label as *resolvable* to an antecedent; when there is none the label used to +# survive, which is how «к тому, что делает ребёнок» produced a node called +# «что». Cyrillic only, deliberately: an English argument cannot reach this, +# so no frozen English figure can move. +NEVER_A_NODE = frozenset( + "что чего чему чем кто кого кому ком как где куда откуда когда зачем почему " + "том тому тем это этом того тот та то те и а но или же ли бы".split() +) + + def _argument(text: str, start: int, end: int, *, side: str) -> dict[str, object] | None: tokens = [(m.start() + start, m.end() + start, m.group()) for m in WORD.finditer(text[start:end])] while tokens and tokens[0][2].lower().strip("'’") in LEADING_DROP: @@ -314,6 +330,11 @@ def _argument(text: str, start: int, end: int, *, side: str) -> dict[str, object tokens.pop() if not tokens: return None + if all(t[2].lower().strip("'’") in NEVER_A_NODE for t in tokens): + # Only function words survived the trimming: there is no thing here to + # name, so this end is ungrounded and the caller abstains rather than + # minting a node out of grammar. + return None if side == "left": tokens = tokens[-MAX_ARG_TOKENS:] else: diff --git a/tests/test_russian_prose_extraction.py b/tests/test_russian_prose_extraction.py index c2cba4b..f215ca2 100644 --- a/tests/test_russian_prose_extraction.py +++ b/tests/test_russian_prose_extraction.py @@ -80,6 +80,45 @@ def test_negation_and_modality_are_read_in_russian(self): self.assertEqual(d["relations"][0]["modality"], "possible") +class NoNonsenseNodesTests(unittest.TestCase): + """Found by the owner reading his own card: it said «что --causes--> ребёнок». + + Two mistakes met. The first draft of the Russian table mapped English + `makes|made|make` onto «делает|делают|сделал» -- a false friend. English + "makes" is causal only in "makes X happen"; Russian «делает» is the + ordinary verb "does", so «к тому, что делает ребёнок» ("to what the child + does") was read as a causal claim. The second is that nothing stopped a + bare complementiser from becoming a node once a cue landed beside it. + + A wrong relation is worse than a missing one here: the whole promise is + that the structure shown is the person's own reasoning. + """ + + def test_delaet_is_not_treated_as_a_causal_cue(self): + text = ("Я бы делала steering assist: система добавляет ограниченный " + "момент к тому, что делает ребёнок, но никогда полностью не " + "забирает управление.") + self.assertEqual(edges(text), [], + "no explicit causal connective here, so nothing may be claimed") + + def test_a_bare_function_word_is_never_a_node(self): + junk = {"что", "чего", "чем", "кто", "как", "где", "когда", "том", "то"} + for text in ( + "Система добавляет момент к тому, что делает ребёнок.", + "Важно то, что ребёнок учится сам.", + "Непонятно, кто вызывает ошибку.", + ): + with self.subTest(text=text): + labels = {n["label"].strip().lower() for n in graph_of(text)["nodes"]} + self.assertEqual(labels & junk, set(), f"{text!r} -> {labels}") + + def test_a_real_causal_claim_in_the_same_document_still_reads(self): + # Removing the false friend must not cost the relations that were right. + got = edges("Мотор через пружинную муфту создаёт небольшой корректирующий момент.") + self.assertEqual(len(got), 1) + self.assertEqual(got[0][1], "causes") + + class CyrillicSegmentationTests(unittest.TestCase): def test_sentences_split_on_a_cyrillic_capital(self): from src.extraction.cue import sentences