Prototype causal rewriting with a syntax DAG #1

Merged
Zayden-Jung merged 5 commits from codex/syntax-dag into main 2026-11-04 08:42:04 +00:00
6 changed files with 290 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Verify syntax DAG
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu24.04-x64-lean4.35
steps:
- name: Check out the revision with Git
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
REVISION: ${{ github.sha }}
run: |
git init .
git fetch --depth=1 "$REPO_URL" "$REVISION"
git checkout --detach FETCH_HEAD
- name: Install wheels in an isolated environment
run: |
python3 -m venv .venv
.venv/bin/python -m pip install --only-binary=:all: -r requirements.txt
- name: Verify graph commits and decoding
run: .venv/bin/python -m unittest -v
+2
View File
@@ -0,0 +1,2 @@
.venv/
__pycache__/
+36
View File
@@ -0,0 +1,36 @@
# Syntax DAG Transformer
An English-language prototype that combines spaCy's tokenizer and learned
dependency parser with explicit graph rules. The graph interface can support
other encoders and relation miners as the prototype evolves.
```text
The road flooded because it rained.
→ Cause: it rained. Effect: The road flooded.
```
Run with Python 3.12 after installing `requirements.txt`.
```sh
python -m syntax_dag --commit 'The road flooded because it rained.'
python -m unittest -v
```
Omit `--commit` to recover the original token surfaces and whitespace. The
command prints one final newline.
1. `Encoder` creates token nodes with original whitespace and head-to-dependent
edges. The causal rule adds clause nodes with edges to their member tokens.
A token can have incoming edges from both a clause and a syntactic head.
2. The rule mines a candidate `cause → effect` edge with provenance from a
trailing `because` clause. The candidate stays separate until committed.
3. `SyntaxDAG.commit` creates a new immutable graph revision and records the
proposal. A cycle rejects the entire edit. Repeating a commit on the same
revision returns that revision.
4. `Decoder` uses committed causal edges to select the clause order and labels.
Text outside the selected clauses retains its original surface form.
The first rule covers simple affirmative past-tense statements with a trailing
finite causal clause. Extraction is heuristic and depends on parser accuracy.
Commits are in-memory graph edits. The prototype uses explicit authorization
via the CLI flag and has no learned rewrite policy.
+5
View File
@@ -0,0 +1,5 @@
spacy==3.8.11
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
# spaCy 3.8.11 imports Click when loading its CLI.
click==8.1.8
+169
View File
@@ -0,0 +1,169 @@
"""Encode English text, commit supported graph edits, and decode a revision."""
import argparse
from dataclasses import dataclass, replace
from graphlib import TopologicalSorter
import spacy
@dataclass(frozen=True)
class Token:
text: str
whitespace: str
@dataclass(frozen=True)
class Clause:
id: int
start: int
stop: int
@dataclass(frozen=True)
class Edge:
source: int
target: int
kind: str
@dataclass(frozen=True)
class Proposal:
edge: Edge
marker: int
rule: str
@dataclass(frozen=True)
class SyntaxDAG:
tokens: tuple[Token, ...]
clauses: tuple[Clause, ...]
edges: tuple[Edge, ...]
proposals: tuple[Proposal, ...]
history: tuple[Proposal, ...] = ()
def __post_init__(self):
nodes = set(range(len(self.tokens))) | {clause.id for clause in self.clauses}
sorter = TopologicalSorter()
for node in nodes:
sorter.add(node)
for edge in self.edges:
if edge.source not in nodes or edge.target not in nodes:
raise ValueError("Edge endpoint is missing")
sorter.add(edge.target, edge.source)
tuple(sorter.static_order())
def commit(self, proposal: Proposal):
if proposal not in self.proposals:
raise ValueError("Proposal was not mined from this graph")
if proposal in self.history:
return self
return replace(
self,
edges=(*self.edges, proposal.edge),
history=(*self.history, proposal),
)
class Encoder:
def __init__(self):
self.nlp = spacy.load("en_core_web_sm", disable=["ner"])
def encode(self, text: str) -> SyntaxDAG:
doc = self.nlp(text)
tokens = tuple(Token(token.text, token.whitespace_) for token in doc)
edges = [
Edge(token.head.i, token.i, token.dep_)
for token in doc
if token.head != token
]
clauses = []
proposals = []
for sentence in doc.sents:
content = [token.i for token in sentence if not token.is_space]
if not content:
continue
sentence = doc[content[0]:content[-1] + 1]
match = self._causal_marker(sentence)
if match is None:
continue
marker, end = match
effect = Clause(len(tokens) + len(clauses), sentence.start, marker.i)
cause = Clause(effect.id + 1, marker.i + 1, end)
clauses.extend((effect, cause))
for clause in (effect, cause):
edges.extend(
Edge(clause.id, index, "contains")
for index in range(clause.start, clause.stop)
)
proposals.append(Proposal(Edge(cause.id, effect.id, "causes"),
marker.i, "trailing-because"))
return SyntaxDAG(tokens, tuple(clauses), tuple(edges), tuple(proposals))
@staticmethod
def _causal_marker(sentence):
markers = [token for token in sentence if token.lower_ == "because"]
if len(markers) != 1 or sentence[-1].text != ".":
return None
marker = markers[0]
verb = marker.head
end = sentence.end - 1
if (
marker.dep_ != "mark"
or verb.dep_ != "advcl"
or verb.head != sentence.root
or not sentence.start <= sentence.root.i < marker.i
or not marker.i < verb.i < end
or sentence.root.pos_ != "VERB"
or verb.pos_ != "VERB"
or sentence.root.tag_ not in {"VBD", "VBN"}
or verb.tag_ != "VBD"
or any(token.is_punct or token.is_space for token in sentence[:-1])
or any(token.dep_ in {"neg", "aux", "conj"} for token in sentence)
or not any(child.dep_ == "nsubj" for child in sentence.root.children)
or not any(child.dep_ == "nsubj" for child in verb.children)
or {token.i for token in verb.subtree if not token.is_punct}
!= set(range(marker.i, end))
):
return None
return marker, end
class Decoder:
def decode(self, graph: SyntaxDAG) -> str:
clauses = {clause.id: clause for clause in graph.clauses}
edits = {}
for edge in graph.edges:
if edge.kind != "causes":
continue
cause, effect = clauses[edge.source], clauses[edge.target]
cause_text = self._surface(graph, cause.start, cause.stop).rstrip()
effect_text = self._surface(graph, effect.start, effect.stop).rstrip()
edits[effect.start] = (cause.stop, f"Cause: {cause_text}. Effect: {effect_text}")
output = []
index = 0
while index < len(graph.tokens):
stop, surface = edits.get(index, (index + 1, self._surface(graph, index, index + 1)))
output.append(surface)
index = stop
return "".join(output)
@staticmethod
def _surface(graph, start, stop):
return "".join(token.text + token.whitespace for token in graph.tokens[start:stop])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("text")
parser.add_argument("--commit", action="store_true", help="Apply mined causal edits")
args = parser.parse_args()
graph = Encoder().encode(args.text)
if args.commit:
for proposal in graph.proposals:
graph = graph.commit(proposal)
print(Decoder().decode(graph))
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
import unittest
from dataclasses import replace
from graphlib import CycleError
from syntax_dag import Decoder, Edge, Encoder, Proposal
class SyntaxDAGTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.encoder = Encoder()
cls.decoder = Decoder()
def test_round_trip(self):
for text in ("", " \t", " It rained.\n\nThe road flooded. "):
with self.subTest(text=text):
graph = self.encoder.encode(text)
self.assertEqual(self.decoder.decode(graph), text)
self.assertFalse(graph.proposals)
def test_causal_commit(self):
text = "The road flooded because it rained. They waited."
graph = self.encoder.encode(text)
self.assertEqual(len(graph.proposals), 1, [
(token.text, token.head.text, token.dep_, token.pos_, token.tag_)
for token in self.encoder.nlp(text)
])
proposal, = graph.proposals
self.assertTrue(any(
len({edge.source for edge in graph.edges if edge.target == node}) > 1
for node in range(len(graph.tokens))
))
committed = graph.commit(proposal)
self.assertEqual(self.decoder.decode(graph), text)
self.assertEqual(
self.decoder.decode(committed),
"Cause: it rained. Effect: The road flooded. They waited.",
)
self.assertEqual(committed.history, (proposal,))
self.assertIs(committed.commit(proposal), committed)
self.assertNotIn(proposal.edge, graph.edges)
def test_cycle_rejects_entire_commit(self):
graph = self.encoder.encode("The road flooded because it rained.")
original, = graph.proposals
reverse = Proposal(Edge(original.edge.target, original.edge.source, "causes"),
original.marker, "test")
committed = replace(graph.commit(original), proposals=(original, reverse))
with self.assertRaises(CycleError):
committed.commit(reverse)
self.assertEqual(committed.history, (original,))
self.assertNotIn(reverse.edge, committed.edges)
def test_negation_and_questions_are_left_alone(self):
for text in ("The road did not flood because it rained.",
"Did the road flood because it rained?"):
with self.subTest(text=text):
self.assertFalse(self.encoder.encode(text).proposals)