Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions demo/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
13 changes: 11 additions & 2 deletions demo/ui/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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});
Expand All @@ -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()
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions demo/ui/strings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions docs/decisions/ADR-0008-russian-prose-extraction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions src/extraction/cue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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|"
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions tests/test_russian_prose_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down