Files
syntax-dag-transformer/syntax_dag.py
T
codex-bot 53e0163941
Verify syntax DAG / verify (push) Successful in 37s
Verify syntax DAG / verify (pull_request) Successful in 23s
Expose GPT-4o tokenizer IDs for graph input and output
2026-11-04 16:48:22 +08:00

195 lines
6.3 KiB
Python

"""Encode English text, commit supported graph edits, and decode a revision."""
import argparse
import json
from dataclasses import dataclass, replace
from graphlib import TopologicalSorter
import spacy
import tiktoken
MODEL_ENCODING = "o200k_base"
@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, ...] = ()
input_ids: tuple[int, ...] = ()
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.tokenizer = tiktoken.get_encoding(MODEL_ENCODING)
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),
input_ids=tuple(self.tokenizer.encode_ordinary(text)),
)
@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_ids(self, graph: SyntaxDAG) -> tuple[int, ...]:
tokenizer = tiktoken.get_encoding(MODEL_ENCODING)
return tuple(tokenizer.encode_ordinary(self.decode(graph)))
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")
parser.add_argument("--ids", action="store_true", help="Emit model token IDs as JSON")
args = parser.parse_args()
graph = Encoder().encode(args.text)
if args.commit:
for proposal in graph.proposals:
graph = graph.commit(proposal)
decoder = Decoder()
text = decoder.decode(graph)
if args.ids:
print(json.dumps({
"encoding": MODEL_ENCODING,
"input_ids": graph.input_ids,
"output_ids": decoder.decode_ids(graph),
"text": text,
}, ensure_ascii=False))
else:
print(text)
if __name__ == "__main__":
main()