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