feat(inbox): Door B derives structure and reprojects the index additively
Door B takes a profile (keyword-only, DEFAULT) and, under a profile carrying facets, derives each dropped document's title, number, hierarchy and cross-references, writes them into the concept's own frontmatter, and projects them into the index entry. The additive requirement is answered by one decision rather than by an algorithm: the index is a PROJECTION of the concept files, recomputed from the whole bundle each round. Nothing is diffed, so the three invariants hold by construction -- rebuild-from-scratch equals incremental byte for byte, re-dropping a document replaces its entry instead of doubling it, and a relation formed in round 3 UPDATES the round-1 entry it is about, which an append-only index could never do. An unresolved pointer is marked '?' in the entry rather than omitted: during build-up, pointing at something not dropped yet is normal, and the dangerous version is the one that leaves no trace. Facet values are validated per file BEFORE the write, so a producer value that breaks the grammar fails that file and not the run. DEFAULT is byte-identical with and without the new parameter, and is asserted so. Door B keeps writing the literal 'generated: true' rather than the profile's ownership stamp -- routing it through the profile would move DEFAULT's bytes and orphan every bundle this door has already written; that is a separate question and answering it here would have answered it silently. 18 new tests; suite 677 -> 695.
This commit is contained in:
parent
52c82bc3d1
commit
7ce548c09b
3 changed files with 566 additions and 16 deletions
|
|
@ -35,7 +35,15 @@ from .materialize import (
|
|||
validate_ingested_at,
|
||||
write_bytes,
|
||||
)
|
||||
from .profiles import DEFAULT
|
||||
from .profiles import DEFAULT, BundleProfile
|
||||
from .structure import (
|
||||
DocumentStructure,
|
||||
derive_document_structure,
|
||||
facet_values,
|
||||
resolve_structure,
|
||||
structure_frontmatter,
|
||||
structure_from_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def inbox_slug(source_filename: str) -> str:
|
||||
|
|
@ -56,7 +64,7 @@ def inbox_slug(source_filename: str) -> str:
|
|||
return slug
|
||||
|
||||
|
||||
def inbox_filename(slug: str) -> str:
|
||||
def inbox_filename(slug: str, *, profile: BundleProfile = DEFAULT) -> str:
|
||||
"""The concept filename for an inbox file.
|
||||
|
||||
The `inbox-` prefix keeps the namespace disjoint from `index.md`, Door A's
|
||||
|
|
@ -68,7 +76,7 @@ def inbox_filename(slug: str) -> str:
|
|||
not give it.
|
||||
"""
|
||||
return check_filename_length(
|
||||
f"{DEFAULT.paths.inbox_prefix}{slug}{DEFAULT.paths.concept_suffix}",
|
||||
f"{profile.paths.inbox_prefix}{slug}{profile.paths.concept_suffix}",
|
||||
code="inbox_slug_too_long",
|
||||
)
|
||||
|
||||
|
|
@ -89,6 +97,8 @@ def render_inbox_concept(
|
|||
source_file: str,
|
||||
source_bytes: bytes,
|
||||
ingested_at: str,
|
||||
profile: BundleProfile = DEFAULT,
|
||||
structure: DocumentStructure | None = None,
|
||||
) -> str:
|
||||
"""Frame extracted text as an inbox concept file with its provenance layer.
|
||||
|
||||
|
|
@ -102,7 +112,7 @@ def render_inbox_concept(
|
|||
# The verdict layer is RESERVED: the promotion gate is the only path into
|
||||
# it, at this door exactly as at Door A's manifest validation — the same
|
||||
# profile decides, each door raises its own typed error.
|
||||
rejection = DEFAULT.types.rejection(okf_type)
|
||||
rejection = profile.types.rejection(okf_type)
|
||||
if rejection is not None:
|
||||
raise MaterializationError(f"okf_type {rejection.reason}", code=rejection.code)
|
||||
# The title is rendered verbatim into `- [title](target)` and into
|
||||
|
|
@ -124,9 +134,17 @@ def render_inbox_concept(
|
|||
"source_file": source_file,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(),
|
||||
"ingested_at": ingested_at,
|
||||
# The literal stamp, NOT `profile.ownership.stamp(...)`. Door B has
|
||||
# written `true` since Phase 2 and `_is_inbox_owned` reads it back;
|
||||
# routing it through the profile would move DEFAULT's bytes to the O2
|
||||
# mapping and orphan every bundle this door has already written. Which
|
||||
# stamp Door B should write is a separate question from this order's,
|
||||
# and answering it here would have answered it silently.
|
||||
"generated": "true",
|
||||
}
|
||||
return f"---\n{DEFAULT.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
||||
if structure is not None and profile.index.facets is not None:
|
||||
frontmatter.update(structure_frontmatter(structure, profile.index.facets.keys))
|
||||
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
||||
|
||||
|
||||
# --- the guard seam -------------------------------------------------------
|
||||
|
|
@ -225,6 +243,7 @@ def process_inbox(
|
|||
*,
|
||||
okf_type: str,
|
||||
gate: Gate,
|
||||
profile: BundleProfile = DEFAULT,
|
||||
) -> InboxResult:
|
||||
"""Convert every file dropped in `inbox_dir` into an OKF concept.
|
||||
|
||||
|
|
@ -242,7 +261,7 @@ def process_inbox(
|
|||
a reserved `okf_type`, and a missing inbox directory.
|
||||
"""
|
||||
validate_ingested_at(ingested_at)
|
||||
run_rejection = DEFAULT.types.rejection(okf_type)
|
||||
run_rejection = profile.types.rejection(okf_type)
|
||||
if run_rejection is not None:
|
||||
raise MaterializationError(f"okf_type {run_rejection.reason}", code=run_rejection.code)
|
||||
inbox = Path(inbox_dir)
|
||||
|
|
@ -264,7 +283,7 @@ def process_inbox(
|
|||
slug_owners: dict[str, list[Path]] = {}
|
||||
for path in dropped:
|
||||
try:
|
||||
name = inbox_filename(inbox_slug(path.name))
|
||||
name = inbox_filename(inbox_slug(path.name), profile=profile)
|
||||
except IngestError as exc:
|
||||
failed.append(FailedFile(source_file=path.name, error=exc))
|
||||
continue
|
||||
|
|
@ -293,8 +312,8 @@ def process_inbox(
|
|||
pre_existing = (
|
||||
{
|
||||
path.name
|
||||
for path in bundle.glob(f"*{DEFAULT.paths.concept_suffix}")
|
||||
if path.name != DEFAULT.index.name
|
||||
for path in bundle.glob(f"*{profile.paths.concept_suffix}")
|
||||
if path.name != profile.index.name
|
||||
}
|
||||
if bundle.is_dir()
|
||||
else set()
|
||||
|
|
@ -334,7 +353,17 @@ def process_inbox(
|
|||
else:
|
||||
rejected.append(blocked)
|
||||
continue
|
||||
structure: DocumentStructure | None = None
|
||||
title = unicodedata.normalize("NFC", path.stem)
|
||||
if profile.index.facets is not None:
|
||||
# Derived from the SANITIZED text, never the extracted text:
|
||||
# deriving from bytes the gate rejected would put unscreened
|
||||
# content in the frontmatter and the index.
|
||||
structure = derive_document_structure(
|
||||
decision.sanitized_text, source_file=path.name
|
||||
)
|
||||
title = structure.title
|
||||
_validate_facets(structure, profile)
|
||||
content = render_inbox_concept(
|
||||
decision.sanitized_text,
|
||||
okf_type=okf_type,
|
||||
|
|
@ -342,6 +371,8 @@ def process_inbox(
|
|||
source_file=path.name,
|
||||
source_bytes=source_bytes,
|
||||
ingested_at=ingested_at,
|
||||
profile=profile,
|
||||
structure=structure,
|
||||
)
|
||||
except OSError as exc:
|
||||
failed.append(
|
||||
|
|
@ -365,13 +396,19 @@ def process_inbox(
|
|||
|
||||
# §6 index — the last disk mutation, and only when something was written.
|
||||
if persisted:
|
||||
index_path = bundle / DEFAULT.index.name
|
||||
index_path = bundle / profile.index.name
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, DEFAULT.index.name, "")
|
||||
for entry in persisted:
|
||||
link_in_index(
|
||||
bundle, entry.path.name, unicodedata.normalize("NFC", Path(entry.source_file).stem)
|
||||
)
|
||||
write_bytes(bundle, profile.index.name, "")
|
||||
if profile.index.facets is None:
|
||||
for entry in persisted:
|
||||
link_in_index(
|
||||
bundle,
|
||||
entry.path.name,
|
||||
unicodedata.normalize("NFC", Path(entry.source_file).stem),
|
||||
profile=profile,
|
||||
)
|
||||
else:
|
||||
_reproject_index(bundle, profile)
|
||||
|
||||
return InboxResult(
|
||||
persisted=tuple(persisted),
|
||||
|
|
@ -379,3 +416,82 @@ def process_inbox(
|
|||
rejected=tuple(rejected),
|
||||
failed=tuple(sorted(failed, key=lambda entry: entry.source_file)),
|
||||
)
|
||||
|
||||
|
||||
def _validate_facets(structure: DocumentStructure, profile: BundleProfile) -> None:
|
||||
"""Refuse a document whose values cannot be rendered as index facets.
|
||||
|
||||
Run BEFORE the write and per file, not at reprojection time, because
|
||||
reprojection happens once for the whole bundle: a value refused there would
|
||||
fail the index for every document instead of the one that carried it, and
|
||||
Door B's standing promise is that one bad file never aborts the run.
|
||||
|
||||
Only the producer's own values can fail here — the relation subjects and
|
||||
markers are this library's own tokens.
|
||||
"""
|
||||
assert profile.index.facets is not None
|
||||
try:
|
||||
profile.index.facets.render(structure_frontmatter(structure, profile.index.facets.keys))
|
||||
except ValueError as exc:
|
||||
raise MaterializationError(str(exc), code="index_facet_invalid") from exc
|
||||
|
||||
|
||||
def _reproject_index(bundle: Path, profile: BundleProfile) -> None:
|
||||
"""Rewrite the managed region of the index from the WHOLE bundle.
|
||||
|
||||
Not an append and not a diff. Every inbox-owned concept is read back, the
|
||||
relations between them are resolved as a pure function of that whole set,
|
||||
and the managed lines are re-emitted in one canonically ordered block.
|
||||
Three properties fall out by construction rather than by argument:
|
||||
|
||||
- rebuild-from-scratch equals incremental update, because both are the same
|
||||
function of the same files;
|
||||
- re-dropping a document replaces its entry instead of doubling it, because
|
||||
the concept name is the identity;
|
||||
- a relation formed in a later round (round 3 supersedes round 1) UPDATES
|
||||
the entry it is about, which an append-only index could never do.
|
||||
|
||||
Everything this library did not write survives verbatim and in order —
|
||||
curated prose, headings, and links to files this door does not own. The
|
||||
derived block sits where the first managed line was, so an operator's
|
||||
layout around it is stable across rounds.
|
||||
"""
|
||||
assert profile.index.facets is not None
|
||||
documents: dict[str, DocumentStructure] = {}
|
||||
for path in sorted(bundle.glob(f"*{profile.paths.concept_suffix}")):
|
||||
if path.name == profile.index.name or not _is_inbox_owned(path):
|
||||
continue
|
||||
documents[path.name] = structure_from_frontmatter(parse_frontmatter(path))
|
||||
|
||||
resolved = resolve_structure(documents)
|
||||
block = [
|
||||
profile.index.render_link(
|
||||
documents[name].title or name,
|
||||
name,
|
||||
facets=facet_values(name, resolved, profile.index.facets.keys),
|
||||
)
|
||||
+ "\n"
|
||||
for name in sorted(documents)
|
||||
]
|
||||
|
||||
index_path = bundle / profile.index.name
|
||||
kept: list[str] = []
|
||||
insert_at: int | None = None
|
||||
for line in index_path.read_bytes().decode("utf-8").splitlines(keepends=True):
|
||||
entry = profile.index.parse_entry(line)
|
||||
# A managed line pointing at a file this door does not own is somebody
|
||||
# else's link that happens to share our shape. Claiming it would delete
|
||||
# curated content on the strength of a regex.
|
||||
if entry is not None and entry.target in documents:
|
||||
if insert_at is None:
|
||||
insert_at = len(kept)
|
||||
continue
|
||||
kept.append(line)
|
||||
if insert_at is None:
|
||||
insert_at = len(kept)
|
||||
# A preserved line without its own newline would run into the first
|
||||
# derived entry, silently merging two lines into one unparseable one.
|
||||
if insert_at > 0 and not kept[insert_at - 1].endswith("\n"):
|
||||
kept[insert_at - 1] += "\n"
|
||||
|
||||
index_path.write_bytes("".join(kept[:insert_at] + block + kept[insert_at:]).encode("utf-8"))
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -427,3 +427,119 @@ def _derived_supersession(
|
|||
)
|
||||
)
|
||||
return proposed
|
||||
|
||||
|
||||
# --- projection to and from frontmatter -----------------------------------
|
||||
|
||||
# The order `derived` lists its members in. Fixed rather than sorted so the
|
||||
# marker reads in the order the fields are established (a title before the
|
||||
# number read out of it), and so two runs over the same document emit the same
|
||||
# bytes.
|
||||
_DERIVED_ORDER = ("title", "number", "parent", "references", "supersedes")
|
||||
|
||||
|
||||
def _render_flow_list(items: Sequence[str]) -> str:
|
||||
# Flow form, never block: this library's parser round-trips a flow sequence
|
||||
# as an opaque value and cannot read a block one at all, so a value we can
|
||||
# write is a value we can read back.
|
||||
return f"[{', '.join(items)}]"
|
||||
|
||||
|
||||
def structure_frontmatter(document: DocumentStructure, keys: Sequence[str]) -> dict[str, str]:
|
||||
"""The structure keys a concept file carries, for the keys a profile names.
|
||||
|
||||
Written into the CONCEPT rather than only into the index, because the index
|
||||
is a projection: a later round rebuilds it by reading these keys back, and
|
||||
a fact that lived only in the index would be lost the moment the index was
|
||||
reprojected.
|
||||
"""
|
||||
values: dict[str, str] = {}
|
||||
for key in keys:
|
||||
if key == "derived":
|
||||
continue
|
||||
if key == "number" and document.number:
|
||||
values[key] = document.number
|
||||
elif key == "parent" and document.parent_number:
|
||||
values[key] = document.parent_number
|
||||
elif key == "version" and document.version:
|
||||
values[key] = document.version
|
||||
elif key == "references" and document.references:
|
||||
values[key] = _render_flow_list(document.references)
|
||||
elif key == "supersedes" and document.supersedes:
|
||||
values[key] = _render_flow_list(document.supersedes)
|
||||
elif key in document.declared:
|
||||
values[key] = document.declared[key]
|
||||
marked = [field_name for field_name in _DERIVED_ORDER if field_name in document.derived]
|
||||
if marked and "derived" in keys:
|
||||
values["derived"] = _render_flow_list(marked)
|
||||
return values
|
||||
|
||||
|
||||
def structure_from_frontmatter(values: Mapping[str, str]) -> DocumentStructure:
|
||||
"""A concept file's stored frontmatter read back as its structure.
|
||||
|
||||
The inverse of :func:`structure_frontmatter` over the keys it writes, plus
|
||||
the door's own `title` and `source_file`. Reading the STORED keys rather
|
||||
than re-deriving from the body is what makes a reprojection cheap and, more
|
||||
importantly, stable: re-derivation would make a bundle's index depend on
|
||||
the version of this library that last touched it.
|
||||
"""
|
||||
return DocumentStructure(
|
||||
title=values.get("title", ""),
|
||||
source_file=values.get("source_file", ""),
|
||||
number=values.get("number"),
|
||||
parent_number=values.get("parent"),
|
||||
version=values.get("version"),
|
||||
references=_parse_flow_list(values["references"]) if "references" in values else (),
|
||||
supersedes=_parse_flow_list(values["supersedes"]) if "supersedes" in values else (),
|
||||
declared=dict(values),
|
||||
derived=frozenset(_parse_flow_list(values["derived"]) if "derived" in values else ()),
|
||||
)
|
||||
|
||||
|
||||
# The suffix a subject wears when nothing in the bundle answers to it. A
|
||||
# pointer that is merely absent from the index is indistinguishable from one
|
||||
# that was never made -- and an absence that does not scream is the most
|
||||
# dangerous state this repo knows.
|
||||
UNRESOLVED_MARKER = "?"
|
||||
|
||||
|
||||
def facet_values(name: str, bundle: BundleStructure, keys: Sequence[str]) -> dict[str, str]:
|
||||
"""One document's facets, as an index entry carries them.
|
||||
|
||||
Relations are rendered as their SUBJECTS, each suffixed with
|
||||
:data:`UNRESOLVED_MARKER` when the bundle holds nothing answering to it, so
|
||||
an index reader sees the difference between "points at N200" and "points at
|
||||
an N200 that is not here". `derived` gathers the document's own inferred
|
||||
fields plus any relation this library proposed rather than read.
|
||||
"""
|
||||
document = bundle.documents[name]
|
||||
values = structure_frontmatter(document, [key for key in keys if key != "derived"])
|
||||
marked = {field_name for field_name in document.derived}
|
||||
|
||||
edges = [edge for edge in bundle.edges if edge.source == name]
|
||||
for kind, key in (
|
||||
("parent", "parent"),
|
||||
("references", "references"),
|
||||
("supersedes", "supersedes"),
|
||||
):
|
||||
if key not in keys:
|
||||
continue
|
||||
subjects = [
|
||||
edge.subject + (UNRESOLVED_MARKER if edge.target is None else "")
|
||||
for edge in edges
|
||||
if edge.kind == kind
|
||||
]
|
||||
if any(edge.kind == kind and edge.derived for edge in edges):
|
||||
marked.add(key)
|
||||
if not subjects:
|
||||
values.pop(key, None)
|
||||
elif kind == "parent":
|
||||
values[key] = subjects[0]
|
||||
else:
|
||||
values[key] = _render_flow_list(subjects)
|
||||
|
||||
listed = [field_name for field_name in _DERIVED_ORDER if field_name in marked]
|
||||
if listed and "derived" in keys:
|
||||
values["derived"] = _render_flow_list(listed)
|
||||
return values
|
||||
|
|
|
|||
318
tests/test_inbox_structure.py
Normal file
318
tests/test_inbox_structure.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
"""Door B, additively: a bundle built up over several drops keeps its structure.
|
||||
|
||||
The hardest requirement in the order, in the operator's own words: documents
|
||||
are dropped into the inbox "en eller flere ganger, altsa additivt". Four things
|
||||
follow, and none of them may be worked around:
|
||||
|
||||
- a document dropped in round 3 can supersede one from round 1, so the index
|
||||
must be UPDATED, not merely appended to;
|
||||
- a cross-reference may point at something not dropped yet — the normal state
|
||||
during build-up, which must stay VISIBLE as unfulfilled rather than vanish;
|
||||
- rebuild-from-scratch and incremental update must agree, byte for byte;
|
||||
- dropping the same file twice must not double an entry or a relation.
|
||||
|
||||
The design answer to all four is one decision: the index is a PROJECTION of the
|
||||
concept files, recomputed from the whole bundle every round. Nothing is diffed,
|
||||
so there is no diffing algorithm to be wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf.inbox import GateDecision, process_inbox
|
||||
from llm_ingestion_okf.profiles import DEFAULT, STRUCTURED_V1
|
||||
|
||||
INGESTED_AT = "2026-07-25T12:00:00Z"
|
||||
|
||||
|
||||
def gate(text: str) -> GateDecision:
|
||||
return GateDecision(sanitized_text=text, disposition="warn")
|
||||
|
||||
|
||||
def drop(inbox: Path, name: str, text: str) -> None:
|
||||
inbox.mkdir(parents=True, exist_ok=True)
|
||||
(inbox / name).write_text(text, encoding="utf-8", newline="")
|
||||
|
||||
|
||||
def run(tmp: Path, *, profile=STRUCTURED_V1, round_name: str = "round") -> None:
|
||||
process_inbox(
|
||||
tmp / round_name,
|
||||
tmp / "bundle",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
|
||||
def index_of(tmp: Path) -> str:
|
||||
return (tmp / "bundle" / "index.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def concept(tmp: Path, name: str) -> str:
|
||||
return (tmp / "bundle" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# --- DEFAULT is untouched -------------------------------------------------
|
||||
|
||||
|
||||
def test_default_is_byte_identical_with_and_without_the_new_parameter(tmp_path: Path) -> None:
|
||||
# The additivity claim at the byte level. If this ever fails, the feature
|
||||
# stopped being additive and every existing Door B bundle churns.
|
||||
for label in ("implicit", "explicit"):
|
||||
drop(tmp_path / label, "n500-vegbygging.md", "# Vegbygging\n\nSee N200.\n")
|
||||
process_inbox(
|
||||
tmp_path / "implicit",
|
||||
tmp_path / "a",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
)
|
||||
process_inbox(
|
||||
tmp_path / "explicit",
|
||||
tmp_path / "b",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=DEFAULT,
|
||||
)
|
||||
for name in ("index.md", "inbox-n500-vegbygging.md"):
|
||||
assert (tmp_path / "a" / name).read_bytes() == (tmp_path / "b" / name).read_bytes()
|
||||
|
||||
|
||||
def test_default_still_labels_the_entry_with_the_filename_stem(tmp_path: Path) -> None:
|
||||
# Title derivation arrives with the profile that asked for it. DEFAULT
|
||||
# states commons' index layer, and changing its label source from here
|
||||
# would change a contract this repo does not own.
|
||||
drop(tmp_path / "round", "n500-vegbygging.md", "# Vegbygging\n\nbody\n")
|
||||
run(tmp_path, profile=DEFAULT)
|
||||
assert "- [n500-vegbygging](inbox-n500-vegbygging.md)\n" == index_of(tmp_path)
|
||||
|
||||
|
||||
# --- what STRUCTURED_V1 adds ----------------------------------------------
|
||||
|
||||
|
||||
def test_the_title_is_derived_from_the_leading_heading(tmp_path: Path) -> None:
|
||||
# The answer recorded in dc9ea59 for the Door B / Door A capability gap.
|
||||
drop(tmp_path / "round", "n500-vegbygging.md", "# Vegbygging\n\nbody\n")
|
||||
run(tmp_path)
|
||||
assert "title: Vegbygging" in concept(tmp_path, "inbox-n500-vegbygging.md")
|
||||
assert "- [Vegbygging](inbox-n500-vegbygging.md)" in index_of(tmp_path)
|
||||
|
||||
|
||||
def test_the_concept_carries_the_derived_structure_in_its_own_frontmatter(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
drop(tmp_path / "round", "n500-vegbygging.md", "# Vegbygging\n\nSee N200.\n")
|
||||
run(tmp_path)
|
||||
head = concept(tmp_path, "inbox-n500-vegbygging.md").split("---")[1]
|
||||
assert "number: N500" in head
|
||||
assert "references: [N200]" in head
|
||||
# The confidence marker, without which the two lines above are assertions
|
||||
# a consumer cannot audit.
|
||||
assert "derived: [title, number, references]" in head
|
||||
|
||||
|
||||
def test_the_index_entry_carries_the_facets_its_document_carries(tmp_path: Path) -> None:
|
||||
# The measured defect, closed: 0 facets in the index against 55/55 in the
|
||||
# documents was the whole reason the OKF arm lost on trap exposure.
|
||||
drop(
|
||||
tmp_path / "round",
|
||||
"n500-vegbygging.md",
|
||||
"---\nstatus: gjeldende\ndate: 2026-01-01\n---\n\n# Vegbygging\n\nbody\n",
|
||||
)
|
||||
run(tmp_path)
|
||||
entry = index_of(tmp_path).strip()
|
||||
assert "number: N500" in entry
|
||||
assert "status: gjeldende" in entry
|
||||
assert "date: 2026-01-01" in entry
|
||||
|
||||
|
||||
def test_a_declared_value_is_not_marked_derived_in_the_index(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "round", "x.md", "---\ntitle: Declared\nnumber: N500\n---\n\nbody\n")
|
||||
run(tmp_path)
|
||||
assert "derived:" not in index_of(tmp_path)
|
||||
|
||||
|
||||
# --- the additive invariants ----------------------------------------------
|
||||
|
||||
|
||||
def test_a_reference_to_a_document_not_yet_dropped_is_marked_unresolved(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
drop(tmp_path / "round", "n500-x.md", "# A\n\nSee N200.\n")
|
||||
run(tmp_path)
|
||||
assert "references: [N200?]" in index_of(tmp_path)
|
||||
|
||||
|
||||
def test_the_marker_clears_when_the_target_arrives_in_a_later_round(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "r1", "n500-x.md", "# A\n\nSee N200.\n")
|
||||
run(tmp_path, round_name="r1")
|
||||
assert "references: [N200?]" in index_of(tmp_path)
|
||||
|
||||
drop(tmp_path / "r2", "n200-y.md", "# B\n\nbody\n")
|
||||
run(tmp_path, round_name="r2")
|
||||
index = index_of(tmp_path)
|
||||
assert "references: [N200]" in index
|
||||
assert "?" not in index
|
||||
|
||||
|
||||
def test_a_later_round_can_supersede_an_earlier_one_and_the_index_UPDATES(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# An index that could only be appended to would leave the round-1 entry
|
||||
# claiming to be current forever.
|
||||
drop(tmp_path / "r1", "old.md", "---\nnumber: N500\nversion: '2018'\n---\n\n# Old\n")
|
||||
run(tmp_path, round_name="r1")
|
||||
assert "supersedes" not in index_of(tmp_path)
|
||||
|
||||
drop(tmp_path / "r2", "new.md", "---\nnumber: N500\nversion: '2021'\n---\n\n# New\n")
|
||||
run(tmp_path, round_name="r2")
|
||||
index = index_of(tmp_path)
|
||||
assert "supersedes: [N500]" in index
|
||||
# ...and the relation is marked as one this library PROPOSED, alongside the
|
||||
# title it also inferred. Both are heuristics and both say so.
|
||||
assert "derived: [title, supersedes]" in index
|
||||
|
||||
|
||||
def test_rebuild_from_scratch_and_incremental_update_agree_byte_for_byte(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# The single most load-bearing test in this delivery. If these two ever
|
||||
# part company, "additive" stops being a property and becomes a hope.
|
||||
files = {
|
||||
"n500-x.md": "# Vegbygging\n\nSee N200 and N300.\n",
|
||||
"n200-y.md": "---\nstatus: gjeldende\n---\n\n# Grunnlag\n\nSee N500.\n",
|
||||
"n300-z.md": "# Tredje\n\nbody\n",
|
||||
}
|
||||
incremental = tmp_path / "incremental"
|
||||
for round_index, (name, text) in enumerate(files.items(), start=1):
|
||||
drop(incremental / f"r{round_index}", name, text)
|
||||
process_inbox(
|
||||
incremental / f"r{round_index}",
|
||||
incremental / "bundle",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=STRUCTURED_V1,
|
||||
)
|
||||
|
||||
scratch = tmp_path / "scratch"
|
||||
for name, text in files.items():
|
||||
drop(scratch / "r1", name, text)
|
||||
process_inbox(
|
||||
scratch / "r1",
|
||||
scratch / "bundle",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=STRUCTURED_V1,
|
||||
)
|
||||
|
||||
assert (incremental / "bundle" / "index.md").read_bytes() == (
|
||||
scratch / "bundle" / "index.md"
|
||||
).read_bytes()
|
||||
|
||||
|
||||
def test_dropping_the_same_file_twice_yields_one_entry(tmp_path: Path) -> None:
|
||||
text = "# Vegbygging\n\nSee N200.\n"
|
||||
for round_name in ("r1", "r2"):
|
||||
drop(tmp_path / round_name, "n500-x.md", text)
|
||||
run(tmp_path, round_name=round_name)
|
||||
index = index_of(tmp_path)
|
||||
assert index.count("inbox-n500-x.md") == 1
|
||||
assert index.count("N200") == 1
|
||||
|
||||
|
||||
def test_curated_prose_in_the_index_survives_reprojection(tmp_path: Path) -> None:
|
||||
# The index is the one file where this library writes beside somebody
|
||||
# else's content. Reprojection rewrites its OWN lines and nothing else.
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "index.md").write_text(
|
||||
"# Bundle\n\nSome curated prose about inbox-n500-x.md.\n",
|
||||
encoding="utf-8",
|
||||
newline="",
|
||||
)
|
||||
drop(tmp_path / "round", "n500-x.md", "# A\n\nbody\n")
|
||||
run(tmp_path)
|
||||
index = index_of(tmp_path)
|
||||
assert "# Bundle" in index
|
||||
assert "Some curated prose about inbox-n500-x.md." in index
|
||||
|
||||
|
||||
def test_a_curated_link_is_not_claimed_by_reprojection(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "index.md").write_text("- [Hand written](curated.md)\n", encoding="utf-8", newline="")
|
||||
(bundle / "curated.md").write_text("# Curated\n", encoding="utf-8", newline="")
|
||||
drop(tmp_path / "round", "n500-x.md", "# A\n\nbody\n")
|
||||
run(tmp_path)
|
||||
assert "- [Hand written](curated.md)\n" in index_of(tmp_path)
|
||||
|
||||
|
||||
def test_reprojection_is_idempotent_when_nothing_new_arrives(tmp_path: Path) -> None:
|
||||
drop(tmp_path / "r1", "n500-x.md", "# A\n\nSee N200.\n")
|
||||
run(tmp_path, round_name="r1")
|
||||
first = (tmp_path / "bundle" / "index.md").read_bytes()
|
||||
(tmp_path / "r2").mkdir()
|
||||
run(tmp_path, round_name="r2")
|
||||
assert (tmp_path / "bundle" / "index.md").read_bytes() == first
|
||||
|
||||
|
||||
def test_a_facet_value_that_breaks_the_grammar_fails_the_file_not_the_run(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# One bad file never aborts the run — Door B's standing promise.
|
||||
drop(tmp_path / "round", "bad.md", "---\nstatus: a; b\n---\n\n# Bad\n")
|
||||
drop(tmp_path / "round", "good.md", "# Good\n\nbody\n")
|
||||
result = process_inbox(
|
||||
tmp_path / "round",
|
||||
tmp_path / "bundle",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=STRUCTURED_V1,
|
||||
)
|
||||
assert [entry.source_file for entry in result.persisted] == ["good.md"]
|
||||
assert [entry.source_file for entry in result.failed] == ["bad.md"]
|
||||
assert result.failed[0].error.code == "index_facet_invalid"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", [DEFAULT, STRUCTURED_V1])
|
||||
def test_the_run_still_reports_every_file_exactly_once(tmp_path: Path, profile) -> None:
|
||||
for name in ("a.md", "b.md", "c.md"):
|
||||
drop(tmp_path / "round", name, f"# {name}\n\nbody\n")
|
||||
result = process_inbox(
|
||||
tmp_path / "round",
|
||||
tmp_path / "bundle",
|
||||
INGESTED_AT,
|
||||
okf_type="reference",
|
||||
gate=gate,
|
||||
profile=profile,
|
||||
)
|
||||
seen = [
|
||||
entry.source_file
|
||||
for bucket in (result.persisted, result.quarantined, result.rejected, result.failed)
|
||||
for entry in bucket
|
||||
]
|
||||
assert sorted(seen) == ["a.md", "b.md", "c.md"]
|
||||
|
||||
|
||||
def test_the_derived_block_is_ordered_by_target_not_by_arrival(tmp_path: Path) -> None:
|
||||
# The ordering that makes rebuild equal incremental, pinned directly rather
|
||||
# than only as a consequence of the byte-comparison above. Without it the
|
||||
# guarantee lives in two `sorted()` calls and nothing states the property
|
||||
# they exist for.
|
||||
drop(tmp_path / "r1", "z-last.md", "# Z\n\nbody\n")
|
||||
run(tmp_path, round_name="r1")
|
||||
drop(tmp_path / "r2", "a-first.md", "# A\n\nbody\n")
|
||||
run(tmp_path, round_name="r2")
|
||||
lines = index_of(tmp_path).splitlines()
|
||||
assert [line.split("](")[1].split(")")[0] for line in lines] == [
|
||||
"inbox-a-first.md",
|
||||
"inbox-z-last.md",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue