This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
name: Verify syntax DAG
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu24.04-x64-lean4.35
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- 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
|
||||
@@ -0,0 +1,2 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
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
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"""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:
|
||||
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_ != "VBD"
|
||||
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} != 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()
|
||||
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
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)
|
||||
Reference in New Issue
Block a user