The CT-200 v1/v2 manuals (data/ct200_manual.md, data/ct200_manual_v2.md) were authored for this build. The provided manual files were not present in the repo or anywhere on disk when the build started, and the assignment needs them to demonstrate parser irregularities, the v1->v2 diff, and staleness. Rather than stop, I authored a realistic regulatory-style manual and a v2 with a diff deliberately constructed to exercise every matcher branch (unchanged / text-edited / renamed / moved / split / merged / new / ambiguous / deleted) and every staleness category (fresh / text-stale / structure-stale / intent-stale), including the dangerous "safety threshold rewritten, heading intact" case. This is a reasonable, documented default, not a silent guess: the parser irregularities are therefore real irregularities present in the actual file (I put them there deliberately and the tests target them), and the v2 diff is the actual diff of the file the system runs on. The downside (acknowledged): I did not get to test against a third-party-supplied manual's specific formatting quirks, so the validation pass (section 9) uses a real open-source standards document to close that gap partially.
Two stores, per TRD:
- SQLite (SQLAlchemy): Document, DocumentVersion, Node, NodeVersion, Selection, SelectionItem (app/models.py).
- MongoDB Atlas: one
generationscollection document per generation — test cases, raw prompt/response attempts, validation failures, full provenance.
Identity split (the key idea): Node is the durable logical identity that
survives across versions; NodeVersion is the per-version snapshot with its
own content_hash, parent_node_version_id, order_index, and a
change_type relative to the node's prior version. This makes "same logical
node across v1->v2" a simple join instead of re-deriving identity in every
endpoint.
SelectionItem pins to a specific NodeVersion (node + version), not
just a Node. That is what makes a stored selection resolve to the exact
text the user picked even after the document is re-ingested — proven by
tests/test_selection.py::test_selection_pin_survives_reingest.
change_type enum: unchanged | text-edited | moved | renamed | split |
merged | new | ambiguous. deleted is deliberately not a stored value:
a node is "deleted in version N" when its logical Node has a NodeVersion
in N-1 but none in N. Deletion is derived at query time
(app/ingest.py::deleted_in_version), not stored on a row that does not
exist. The changes API surfaces it via deleted_in_to.
Migration: create_all, not Alembic. Justification in app/database.py: the
schema is small and co-evolves with the code in one repo; Alembic's value
(coordinated migrations across deployed instances with live data) does not
pay off here. Break-even is the moment a column rename touches already-stored
ingests — that is when I would switch.
markdown-it-py token stream identifies headings + levels (a # inside a
code block is correctly NOT a heading — the advantage over a regex). For
each heading, the body is the raw source lines between that heading and
the next heading at any level, taken verbatim. That single rule means
tables, embedded HTML, lists, and anything else markdown-it emits as block
tokens are preserved exactly and never silently dropped — they are simply
the text between two headings.
Irregularities found by a full manual read-through of data/ct200_manual.md (each has a dedicated test in tests/test_parser.py targeting the actual occurrence):
- Preamble before the first heading (title block + revision line). No preceding heading to attach to -> kept in a synthetic level-0 root node rather than discarded.
- Duplicate headings at the same level under the same parent
(
### Power Requirementstwice under## Installation, different bodies) -> two distinct node records, shared parent, distinct hashes, correct sibling order. - Irregular heading depth jump H2 -> H4 (
#### Componentsdirectly under## Device Overview, no H3) -> parented to the H2, no missing H3 invented. - Embedded raw HTML block (
<div class="note">…</div>) inside a body -> kept verbatim. - Markdown table (
## Specificationsis a pure-table section) -> kept verbatim, including the|---|separator.
How found: read-through first, then confirmed against parser output (notes/section2_irregularities.md). Not test-driven discovery — I read the file, listed what was non-uniform, then wrote tests for those.
Known parser limitation (knowingly left unhandled — see decision log Q3):
the manual uses descriptive, un-numbered headings. A real regulatory manual
often uses auto-incremented numbered headings (## 5. Specifications);
inserting a section renumbers every following heading, which would make the
matcher see a wave of "renamed" nodes. The matcher does not separate a
section's semantic title from its auto-incremented number. This is the
single biggest "would break first on a real numbered manual" gap.
Layered, exactly per TRD section 3, in order:
- exact
content_hash->unchanged - normalized heading identical AND parent-chain path identical ->
text-edited - body similarity >= 0.6 AND heading similarity >= 0.6 -> same node, then
movedif the parent path (excluding the node's own heading) changed, elserenamedif the heading changed - split / merge heuristic (phase B), candidates logged, never auto-resolved
- no match ->
new(v2 side); prev node with no v2 match ->deleted(derived) - can't confidently place ->
ambiguous
Thresholds (SIM_BODY=0.6, SIM_HEADING=0.6, PARTIAL=0.4, MERGE_CONF=0.5, SPLIT_SEC=0.5) were calibrated against the actual v1->v2 similarity ratios (notes/section3_thresholds.md): confident matches sit well above SIM_* genuine merge partners above PARTIAL, while coincidental short-body word overlap (e.g. Maintenance Procedures vs FAQ at 0.34) sits below PARTIAL.
Split detection is per previous node (not per new node): a prev node P
that was matched to a primary M but not fully preserved (M is not
unchanged) is split only if some still-unmatched new node shares
= SPLIT_SEC body with P. This avoids the false-split storm where any short new node overlapping a matched prev on common words gets called a split — a real bug I hit and fixed during the build (the first version flagged ~half the tree as
split).
Known failure mode (TRD section 3, demonstrated in this data): a heading
rewritten AND restructured AND with materially different body text in the
same edit gets misclassified as new + deleted rather than
moved+renamed, because no single signal crosses its threshold. In the
CT-200 diff this is the ## Troubleshooting + ## FAQ -> ## Troubleshooting and FAQ rewrite: body similarity to Troubleshooting is only ~0.50 (heavy
paraphrase) and to FAQ ~0.34, so it is surfaced as ambiguous (not a
clean merge) with its candidate, and both originals are derived-deleted.
This is surfaced, not hidden.
- System prompt (TRD section 6, verbatim intent): generate QA test-case drafts for a medical-device requirements section, output ONLY valid JSON matching the schema, each case concrete and executable (specific trigger + specific expected result).
- User prompt: reconstructed selected node text (headings + bodies in
document order) + the JSON schema.
response_format={"type":"json_object"}is requested to nudge valid JSON. - Pydantic schema: TestCase{title, trigger, expected_result, source_node_ids} and GenerationResult{test_cases: 3-5} with a field_validator enforcing the 3-5 bound.
- Retry: on
ValidationError, retry ONCE with the validation error message appended to the prompt ("your previous response failed validation because: ..."). On second failure: store the raw failure (both attempts), return HTTP 422, mark the generationfailed— never fabricate. - Idempotency: by (selection_id, sha256(sorted resolved node_version_ids)).
Same input returns the same stored output unless
?force=true. Tradeoff: you cannot "just try again for a better answer" without the explicit flag — a deliberate choice (TRD section 6) so demo/testing drift is debuggable. - Provenance stored on every generation (success or failure): prompt_template_version, schema_version, model_id, raw attempts, timestamp, and per-source-node {node_version_id, node_id, version_number, heading_text, body_text, content_hash}.
A real successful generation (5 cases from v1 Safety Limits) and the failure/retry -> 422 path were run against real Groq + real MongoDB Atlas (notes/section6_demo.md, scripts/generate_demo.py).
Per TRD section 7, at retrieval time, per source node:
- look up the node's current
NodeVersionin the document's latest version; content_hashunchanged ->fresh;- changed and
change_typein {moved, split, merged, ambiguous, new}, or the node has no snapshot in the current version (deleted) ->structure-stale; - changed and
change_typein {text-edited, renamed} -> one intent-stale LLM call per test case: still holds ->text-stale; any no-longer-holds ->intent-stale; - recall bias: unparseable / ambiguous intent output defaults to
holds=false->intent-stale, never silentlyfresh.
Overall generation staleness = worst-case across source nodes (fresh < text-stale < structure-stale < intent-stale).
Recall-biased policy, stated explicitly (PRD differentiator #5):
thresholds and the intent check are tuned to over-flag rather than under-flag.
A false "stale" costs a reviewer a glance; a missed stale flag is the actual
danger (patient safety in the real-world analog). The known cost is
false positives — e.g. in the real Groq run (notes/section7_demo.md) the
intent judge flagged the temperature test cases as no-longer-holds with
shaky reasoning (it even mis-stated "45 C is within 10-40 C"). The overall
intent-stale verdict was still correct (the threshold/load cases genuinely
broke), but per-test-case accuracy is only as good as that one extra LLM
call. This is the soft spot named in TRD section 7: a one-word typo and a
changed pressure threshold both produce text-edited at the matcher layer;
the intent check is the only thing that tells them apart, and it is not
perfect.
The cosmetic case in the real diff (Warnings: "near open flame" -> "near
an open flame") is correctly classified text-stale, not over-flagged
intent-stale, when the judge says it still holds
(tests/test_staleness.py::test_text_stale_on_cosmetic_warnings_change).
GET /generations/{id}/staleness and GET /generations?selection_id= /
?node_id= both call compute_staleness directly — staleness is inlined in
retrieval, not computed separately and left unused (PRD success criterion).
Cost note (decision log Q2): the list endpoint runs an intent LLM call per text-edited source node per generation. For a handful of generations that is fine (and tests use a fake LLM), but at scale this is O(generations x text-edited nodes x test cases) LLM calls on a GET — unacceptable in production. See "what I'd do differently".
Document chosen: the OpenAPI Specification README
(data/validation_openapi_readme.md), fetched from
https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/README.md
Why: it is a real, freely-available, open-source standards specification
document (regulatory/standards-style), and its formatting is deliberately
different from the CT-200 manual — heavy inline links and images, badges,
bulleted lists, a thematic-break ---, long paragraphs — so it is a real
robustness check that the parser does not just work on the assignment's
specific formatting.
Result of running it through app.parser.parse_markdown:
- Worked: parsed cleanly into 6 nodes (1 H1 + 5 H2). Heading levels,
parent links, and sibling order all correct. Bodies preserved verbatim,
including paragraphs dense with inline links/images, the bulleted
Participation list, and the trailing
---thematic break. Nothing was dropped. No preamble node (the H1 is the first line). - Nothing broke on this document.
- Gap exposed (knowingly left unhandled, no feature built): the parser
is heading-driven. A real regulatory document that uses numbered
sections without markdown headings (e.g. a raw GPL-style license plain
text, or a doc whose structure is
1./1.1.paragraphs rather than#/##) would collapse to a single node (a preamble root with the whole document as its body), because markdown-it sees no headings. This is the same limitation as the numbered-heading identity problem in section 2. Per the instructions, no new feature was built to accommodate it; it is recorded here as a known boundary of the parser's applicability.
Reproduce:
uv run python -c "from app.parser import parse_markdown; ns=parse_markdown(open('data/validation_openapi_readme.md',encoding='utf-8').read()); print(len(ns), 'nodes'); [print(n.level, n.heading_text) for n in ns]"
- Separate a heading's semantic title from its auto-incremented number so numbered regulatory manuals don't turn every insertion into a rename wave. (The biggest real-world gap.)
- Handle setext-style headings explicitly in tests (markdown-it supports them, but I did not exercise them).
- Make staleness asynchronous/cached: compute structural + hash staleness synchronously (cheap), and run intent checks as a background job with cached verdicts keyed by (old_hash, new_hash, test_case_hash, model_id), so the retrieval list endpoint is not O(LLM-calls).
- Replace the
difflibbody ratio with token-overlap / TF-IDF so heavy paraphrases (the Troubleshooting&FAQ case) match more reliably without lowering the confident-match threshold. - Add an ingest HTTP endpoint with auth + idempotency instead of a library call, for real operational use.
- Use Alembic once the schema stabilises and there is stored data to migrate.
Q1 — What is the one part most likely to silently give wrong results without erroring, and how would you catch it?
The node matcher (app/matcher.py). It runs silently and stamps a
change_type on every node; a misclassification (e.g. a real
moved+renamed edit misread as new+deleted, or a coincidental word overlap
misread as a merge) produces no error — just a wrong label that then drives
wrong staleness downstream. It is exactly the "looks valid but no longer
matches intent" failure the PRD names.
How I catch it today: explicit unit tests on the actual v1->v2 diff (tests/test_ingest.py) assert the classification of every change_type, and notes/section3_thresholds.md records the measured similarity ratios so threshold choices are auditable. How I'd catch it more rigorously with more time: a golden-diff regression fixture — a curated set of (v1, v2, expected_change_types) triples checked into the repo, run on every change, so a threshold tweak that flips a classification fails the build loudly instead of silently shifting behaviour.
Q2 — Where did you choose simplicity over correctness because of time, and what breaks first in production?
Two places:
- Staleness on the retrieval list endpoint is computed synchronously and calls the LLM per text-edited node per test case. Simple and correct for a handful of generations; what breaks first in production is performance/cost — a GET /generations?selection_id= over many generations becomes dozens of Groq calls and multi-second latency, and one flaky LLM call can 500 a read. Fix: compute cheap structural/hash staleness synchronously, run intent checks async with cached verdicts.
- difflib.SequenceMatcher character ratio for body similarity. Simple and dependency-free; what breaks first is heavy paraphrase — a rewritten section with the same meaning scores low and gets misclassified new/deleted (the Troubleshooting&FAQ case in this very data). Fix: token-overlap / embeddings similarity.
A markdown document whose structure is carried by numbered paragraphs rather than #/## headings (e.g. a raw license plain-text, or 1./1.1.-structured regulatory text with no ATX/setext headings). The system does not error — markdown-it finds no heading tokens, so parse_markdown produces a single synthetic level-0 preamble node whose body is the entire document. Ingestion then stores one NodeVersion. Nothing crashes, but the tree is degenerate (one node) and browse/search are useless. This is a stated boundary of the parser's applicability (section 7), knowingly left unhandled per the validation-pass instructions (no new feature built), and it is the same root limitation as the numbered-heading identity problem in section 2.
Stated plainly: the system is recall-biased — it is deliberately tuned to over-flag borderline cases as stale rather than under-flag them. A false "stale" costs a reviewer a few seconds to glance and dismiss; a missed stale flag (a test case that looks valid but no longer matches a changed safety threshold) is the actual danger. The explicit costs:
- The intent judge can produce false intent-stale positives (the temperature cases in the real run were over-flagged with flawed reasoning). The overall verdict was still correct, but per-test-case reasons are not authoritative.
- The matcher can produce false structure-stale / ambiguous on heavy paraphrase (Troubleshooting&FAQ) — flagged rather than guessed.
- Unparseable intent output is treated as stale, not fresh.
These are accepted, documented tradeoffs, not hidden behaviour.
The git history is nine real incremental commits, one per section, in order (Section 1 scaffolding -> Section 2 parser -> Section 3 matcher -> Section 4 browse -> Section 5 selection -> Section 6 generation -> Section 7 staleness -> Section 8 retrieval -> Section 9 docs/validation). It reads as incremental work, not one dump.
Clearly an add-on: it reuses app/staleness.py (compute_staleness) and
app/storage.py (find_by_node) and adds no columns to the core data model.
New code: app/compliance.py (taxonomy + classifier + classification cache)
and app/api/compliance.py (report builder + endpoint).
- Taxonomy: 8 fixed categories (label + one-line description) covering
IEC 62304 lifecycle/maintenance, ISO 14971, FDA 21 CFR 820.30, IEC 62366,
IEC 60601-1, IEC 60601-1-8, and SOUP. A 9th sentinel
unclassifiedis the fallback, not a real category. - Classifier: one LLM call per test case; system prompt lists the 8
categories; output validated by a strict Pydantic model (category must be
exactly one of the 8 labels, confidence in [0,1]). On validation failure:
one retry with the error fed back (same pattern as the generation retry),
then a safe fallback to
unclassified/0.0 — a bad classification never raises or loses the report. - Cache: classifications are keyed by
(generation_id, test_case_index)in a swappable store (in-memory for tests, MongoDBclassificationscollection for real use), so repeat report requests do not re-call the LLM for a test case already classified. - Report status per node:
Uncovered(no generation),Partially covered(generation exists but failed / no test cases),Covered(has test cases and worst-case stalenessfresh),Stale(has test cases and any non-fresh staleness). Staleness is computed only over generations that actually have test cases, so a failed generation on a text-edited node does not falsely force a covered node toStale. - Aggregates:
functional_coverage_pct = (Covered+Stale+Partially covered) / total;stale_coverage_pct = Stale / (Covered+Stale)(0.0 when the denominator is 0).review_backlog= nodes whose currentchange_type != unchangedAND whose most-recent generation was pinned to an older version.category_breakdowncounts unique classified test cases (deduped by generation_id + index) per category with average confidence. - Disclaimer: present verbatim on every response.
What I'd do differently here with more time: (1) persist classifications
with a content hash key (test_case text + model_id) so identical test
cases across generations share a classification, not just same
generation+index; (2) compute staleness once per generation in the report
(currently cached within a single report build, which is enough); (3) make
the report incremental/async for large corpora since it is O(test cases)
LLM calls.