Skip to content

M1: block model, readers, profiles, semantic pass, sample pair, Pyodide CI - #129

Merged
houfu merged 11 commits into
mainfrom
claude/m1-milestone-plan-jwmz47
Sep 4, 2026
Merged

houfu merged 11 commits into
mainfrom
claude/m1-milestone-plan-jwmz47

Conversation

@houfu

@houfu houfu commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

What this lands

The M1 milestone in full: the block model, both 1.0 readers, the built-in profiles, the semantic pass, the section 3a sample pair with its expected trees, and the Pyodide CI check. Nothing is exported from the top-level package yet; Redlines, Document and the CLI are unchanged apart from one import line.

Wave A: foundations

Wave B1: text reader, built-in profiles, Pyodide CI

  • redlines/readers/labels.py and text.py — the plain-text reader over PRD § 6b's five stages (Plain-text reader: normalise, segment, re-join wraps, detect labels, infer hierarchy #102). The § 6b hard cases live under tests/corpus/hard_cases/: four pass (alpha/roman at (i), numbering restarts inside schedules, one-line clauses that look like headings, mixed label styles) and three are strict xfails with reasons (run-on definitions, cross-references in prose, PDF page headers, which must not crash and do not).
  • redlines/profiles/builtin/ — generic, contract and markdown as commented YAML (Built-in profiles: generic, contract, markdown #101). The markdown profile repeats most of contract's rules; that is the evidence ADR-0028 asked Built-in profiles: generic, contract, markdown #101 to gather before deciding on composition, and the decision is left open.
  • Pyodide job in python-package.yml (Pyodide import check in CI #109). Its first run found a real bug: an undeclared typing_extensions import that broke clean installs on Python 3.10 and in the browser. Fixed here.
  • A reconciliation commit after the reader met the built-in profile: single-letter (i) now reaches the style stack, hard-wrap re-joining follows the PRD's lowercase rule, and the stack prefers the smaller forward jump when two runs are open.

Wave B2: markdown reader, semantic pass

Wave C: the sample pair

  • tests/corpus/sample_pair/ — the PRD § 3a demo agreement (Section 3a sample pair and its expected block trees #108): a services agreement between Northwind Systems Limited and Harbour Foods Group Limited, in markdown and as a plain-text twin, plus an amended version carrying exactly one of each detectable change. CHANGES.md lists the eight changes with their addresses. expected/ freezes the four trees; tests/test_sample_pair.py rebuilds and compares them and asserts the structural facts the pair exists to show. The change-tree golden is M2's.

Follow-ups

  • redlines/pipeline.py — read_document() composes the three stages a caller otherwise spells out by hand. Format is detected when not given, and an undetectable one raises with the detection reason quoted so the "coming in 1.1" message reaches the caller. A profile can be a built-in name, a Profile, a mapping or a file, with a built-in name resolved before any path so "contract" cannot be shadowed by a file of that name. With no profile it applies the default the roadmap already recorded: contract for text, markdown for markdown. The sample-pair regenerate script now calls it and the goldens are byte-identical afterwards.
  • redlines/profiles/builtin/ is now a package, holding both the loader and the three YAML files it reads. The module and the data directory previously shared a name. No import spelling changed and the wheel listing is unchanged apart from the moved file.
  • ROADMAP.md gets the M1 completion entry, in the shape M0 already has.

M1 exit criteria

  • The sample pair parses into the expected trees under contract and markdown: 53 tests in test_sample_pair.py.
  • Every § 6b hard case has a test: four pass, three strict xfail.
  • The wheel imports in Pyodide: the pyodide job is green on every push.

Checks

  • 939 passed, 1 skipped, 3 xfailed; mypy strict clean on 46 files; black clean.

Decisions

Settled: the sample agreement text stands as drafted, and the clause role question is now tracked as #130 rather than blocking this PR.

Still open, neither blocking:

  • recital and signature are match: heading in the contract profile, so the blocks under those headings carry no role; schedule uses ancestor_heading and reaches the whole subtree. One-line profile change if you want them to match.
  • Composition. ADR-0028's revisit condition is met by the built-in profiles, with the overlap pinned in a test.

Worth knowing rather than deciding: the four expected trees total about 480 KB of pretty-printed JSON, and the plain-text twin states Schedule 1's deliverables as sentences rather than a table, because the text reader has no tables (ADR-0013). The twins therefore diverge in exactly that subtree, and the test says so.

Closes #98, closes #99, closes #101, closes #102, closes #103, closes #104, closes #105, closes #106, closes #107, closes #108, closes #109.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT

Formatting only. Sixteen files had drifted from black's defaults; this
brings them back so that black --check is clean before the M1 code lands
and feature diffs stay readable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
Lands the foundations every M1 reader builds on:

- redlines/blocks.py: the frozen block model (#98) with role and spans
  (#99), matched_by, confidence and dropped reporting with a derived
  fallback_count (#106); XPath-style addresses assigned by one function,
  a heading breadcrumb, and to_dict/from_dict round-tripping.
- redlines/readers: the runtime-checkable Reader protocol and registry
  (#105), a placeholder ParagraphReader as the degrade path, and a shared
  input size cap; redlines/readers/detect.py detects txt and md by
  extension then content, and reports unknown types instead of guessing
  (#107).
- examples/custom_reader.py: a worked third-party reader, documented in
  examples/README.md and executed by the test suite.
- ADR-0029 (address syntax) and ADR-0030 (matched_by and confidence).

Nothing is exported from the top-level package yet; Redlines, Document
and the CLI are unchanged.

Refs #98, #99, #105, #106, #107.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
redlines/readers/labels.py holds what the markdown reader will reuse:
label detection against a profile's patterns, a style stack that
resolves alpha/roman ambiguity from the numbering run, continuation
attachment and heading scoring. redlines/readers/text.py is the
PlainTextReader over the five PRD § 6b stages, registered for "text" in
place of the ParagraphReader placeholder, and the enforcement point for
the ADR-0028 size cap. Every stage records what it decided in attrs.

The PRD § 6b hard cases live under tests/corpus/hard_cases/. Four pass:
alpha/roman ambiguity at (i), numbering restarts inside schedules,
one-line clauses that look like headings, mixed label styles. Three are
strict xfails with reasons: run-on definitions, cross-references in
prose, PDF page headers (which must not crash, and do not).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
Three commented YAML profiles under redlines/profiles/builtin/, loaded
by name through builtin_profile() and validated by the existing loader.
generic is paragraphs only; contract covers decimal, alpha, roman and
word labels, schedule resets, the definitions, recital, schedule and
signature roles and the defined_term, cross_reference, party, date and
amount spans; markdown carries the same rules for what survives syntax
stripping, since headings come from #.

The markdown profile repeats most of contract's span extractors and
role rules, which is the evidence ADR-0028 asked #101 to look for
before adding a composition mechanism. Pinned in a test; the decision
is left open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
A new pyodide job in python-package.yml builds the wheel, loads it into
Pyodide 0.28.3 under Node 22 through micropip, and imports redlines and
every M1 subpackage. The first run of that check found a real bug: the
package imports typing_extensions without declaring it, so a clean
install on Python 3.10 or in the browser fails. Unpack now comes from
typing on 3.11+ and typing-extensions is declared for older Pythons.

Also folds in four small wave A follow-ups: the custom_reader example
output in examples/README.md, its hardcoded file name, and two
detect_format edge cases (OLE reason wording, magic bytes in str input).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
@houfu houfu changed the title M1 wave A: block model, reader interface, format detection M1 waves A and B1: block model, readers, built-in profiles, Pyodide CI Sep 3, 2026
The reader and the profiles were built independently, and meeting them
exposed three defects the isolated tests could not see:

- The built-in roman_paren pattern required two letters, so a bare "(i)"
  never reached the style stack and the PRD's canonical case ((i) after
  7.2 is roman, one level deeper) came out alpha. The pattern now admits
  single letters and the stack's ranking decides, as designed.
- Hard-wrap re-joining had dropped the PRD § 6b condition that the next
  line begins lowercase, so "2. Charges" swallowed the body line under
  it. The rule is restored; a capitalised resumption still joins when
  the previous line is long enough to be a plausible wrap and is not
  heading-shaped.
- With both an alpha and a roman run open, a value continuing neither
  exactly fell to profile order, so "(x)" after "(v)" popped out of the
  roman sub-list. The open-style rank now prefers the smaller forward
  jump within its run.

tests/test_reader_profiles_integration.py runs the text reader with
every built-in profile over the hard cases so this seam stays covered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
redlines/readers/markdown.py reads ATX and setext headings, ordered and
unordered lists nested by indentation, pipe tables, fenced code and
blockquotes with stdlib re, then hands list-item and paragraph text to
the same label detection, hierarchy stack, continuation and heading
scoring the plain-text reader uses. The tree shape is the text reader's,
so a markdown contract and its plain-text twin produce the same kinds,
labels, levels and addresses; a twin fixture proves it block by block.
Horizontal rules, HTML blocks, link reference definitions and images
are dropped and reported. Quoted material is read but never structures
the document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
redlines/semantic.py adds apply_semantics(tree, profile), a pure pass
that assigns roles from the profile's role rules (first match wins;
ancestor_heading resolves nearest-first per ADR-0028) and spans from
its extractors (all run, every non-overlapping match kept). A
cross_reference span carries the referenced label as its value so M2
can report a reference that followed a renumbering. The PRD § 6b
definitions heuristic (quoted term, "means", text) is the one rule the
profile format cannot express and is written in Python behind the
definition role, recording its decision in attrs.

Block now validates that every span lies within its text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
@houfu houfu changed the title M1 waves A and B1: block model, readers, built-in profiles, Pyodide CI M1: block model, readers, profiles, semantic pass, Pyodide CI Sep 3, 2026
tests/corpus/sample_pair/ holds the demo agreement from PRD § 3a: a
forty-odd clause services agreement between two fictional parties, in
markdown and as a plain-text twin, and an amended version carrying
exactly one of each change the engine is meant to detect: a changed
definition, a clause moved from section 7 to section 9 with an edit in
its body, a renumbering from an inserted clause, a cross-reference that
follows it, a deleted sub-clause, an inserted table row, a whitespace-
only non-change, and an edit inside a repetitive schedule. CHANGES.md
lists each with its addresses.

expected/ freezes the four trees (text under contract, markdown under
markdown, through the semantic pass); regenerate.py rewrites them and
tests/test_sample_pair.py rebuilds each tree and asserts equality plus
the structural facts the pair exists to show, including that the text
and markdown twins agree block by block. The change-tree golden is M2's.

The hand-built twin trees in tests/test_semantic.py are replaced by the
real readers over the merged twin fixture; one of them had encoded a
wrong level.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
@houfu houfu changed the title M1: block model, readers, profiles, semantic pass, Pyodide CI M1: block model, readers, profiles, semantic pass, sample pair, Pyodide CI Sep 3, 2026
Two follow-ups from the M1 run, neither new milestone scope.

redlines/profiles/builtin.py moves into redlines/profiles/builtin/, the
directory holding the three YAML files it loads. The module and the data
directory previously shared a name; import resolution happened to prefer
the module, but the collision was confusing to read and fragile under
namespace-package tooling. Making the directory a real package removes
the ambiguity without changing a single import spelling, and the wheel
listing is unchanged apart from the moved file.

redlines/pipeline.py adds read_document(), which composes the three M1
stages a caller otherwise has to spell out: pick a profile, read with the
reader for the format, apply the semantic pass. Format is detected when
not given, and an undetectable one raises with the detection reason
quoted, so the "coming in 1.1" message reaches the caller. A profile can
be a built-in name, a Profile, a mapping or a file; a built-in name is
resolved before any path, so "contract" can never be shadowed by a file
of that name. With no profile it applies the default the roadmap already
recorded: contract for text, markdown for markdown.

The sample-pair regenerate script now calls it, and the goldens are
byte-identical afterwards. tests/test_sample_pair.py keeps spelling the
pipeline out by hand on purpose: that duplication is what makes a drift
between the two a test failure rather than a silently regenerated golden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
Adds the completion entry M0 already has: the evidence for each of the
three exit criteria, the two ADRs the milestone was asked to write, and
the bug the Pyodide check found on its first run.

Also records the two decisions M1 surfaced and did not take. The profile
format cannot assign a clause role, because all three role match kinds
are structural, so 72 of the 102 blocks in the sample pair carry none;
that is now #130, to be decided before M2 has change nodes carry roles.
ADR-0028's composition revisit condition is met and left open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QosmZFLHNUw7kSDgG3ejXT
@houfu
houfu merged commit e1f2a62 into main Sep 4, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment