feat(importer): Door C projects the sender's own facets into the index
vegnormal-okf measured the gap on 2026-08-27: the arm reading DEFAULT's index.md scored 0 hits of 8, the arm reading a faceted index of the same frontmatter scored 25 of 29. Same bundle, same concepts, same model. The DEFAULT arm did not answer wrongly, it abstained -- the metadata is in the bundle and the index throws it away (30 974 characters over 269 requirements, 0 occurrences of any of the eight facts). FacetPolicy and STRUCTURED_V1 already did this. They did not reach Door C. `import_bundle` now takes a keyword-only `profile` defaulting to DEFAULT, so every existing call site emits the bytes it always did, and `link_in_index` takes the facets to render. Door C PROJECTS and never DERIVES, which is the answer to the objection this work opened with: deriving structure for a document a third party wrote would put our inference into an index entry ABOUT their bytes, where it reads as their claim. The concept file was already verbatim; the entry describing it now is too. Where the sender carries `derived`, THEIR list travels unchanged, so a reader can still see which of the sender's facts the sender inferred. The projection asks the policy which keys to carry and never what a key means. That is what makes it work for a meeting note as well as a numbered norm -- nothing in it can key off a numbering scheme -- and it is why a consumer whose concepts are named by UUID can get `title` into the index by naming the key, with no change here. Two things measured during the work rather than assumed: - A value carrying the policy's own joiner cannot be rendered. Door C's tolerance is structural and it refuses no sender on form, so the FACET is dropped and the concept still merges -- reported per concept and key in `ImportResult.unrendered_facets`, never dropped silently. - A faceted entry can go stale where a flat one never could: the collision gate refuses an updated concept, so the operator's only route is to remove the merged file and re-import, after which the file said `gjeldende` while the index still said `utkast`. A faceted entry for a present target is now refreshed in place instead of skipped. Unfaceted callers keep the early return byte for byte. Suite 695 -> 707; ruff and mypy --strict clean. Order 20260826T224500Z-873805419-from-vegnormal-okf.
This commit is contained in:
parent
69001542b2
commit
1f7d3502b8
3 changed files with 491 additions and 23 deletions
|
|
@ -286,26 +286,78 @@ def _update_index_lines(
|
|||
index_path.write_bytes("".join(updated).encode("utf-8"))
|
||||
|
||||
|
||||
def _refresh_index_entry(
|
||||
index_path: Path,
|
||||
target_name: str,
|
||||
label: str,
|
||||
facets: Mapping[str, str],
|
||||
*,
|
||||
profile: BundleProfile,
|
||||
) -> None:
|
||||
"""Re-render the managed line for `target_name`, in place and alone.
|
||||
|
||||
Keyed on the policy's entry pattern and on the parsed target, never on a
|
||||
substring: the index is the one file this library writes beside somebody
|
||||
else's prose, so a curated line that merely MENTIONS the target has to
|
||||
survive verbatim, and so does its own line ending.
|
||||
"""
|
||||
lines = index_path.read_bytes().decode("utf-8").splitlines(keepends=True)
|
||||
updated: list[str] = []
|
||||
changed = False
|
||||
for line in lines:
|
||||
content = line.rstrip("\r\n")
|
||||
ending = line[len(content) :]
|
||||
match = profile.index.entry_pattern.match(content)
|
||||
if match is not None and match.group("target") == target_name:
|
||||
refreshed = profile.index.render_link(label, target_name, facets=facets)
|
||||
if refreshed != content:
|
||||
line = refreshed + ending
|
||||
changed = True
|
||||
updated.append(line)
|
||||
if changed:
|
||||
index_path.write_bytes("".join(updated).encode("utf-8"))
|
||||
|
||||
|
||||
def link_in_index(
|
||||
bundle_dir: Path, target_name: str, label: str, *, profile: BundleProfile = DEFAULT
|
||||
bundle_dir: Path,
|
||||
target_name: str,
|
||||
label: str,
|
||||
*,
|
||||
profile: BundleProfile = DEFAULT,
|
||||
facets: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
# §6: idempotent by target — a link whose target is already present in
|
||||
# the index is never added twice.
|
||||
#
|
||||
# `profile` is keyword-only with a default because this function is public
|
||||
# and called from all three doors (A here, B in inbox.py, C in importer.py).
|
||||
# Doors B and C keep the default, which is the behaviour they already had;
|
||||
# which profile THEY should own is a separate question, and answering it by
|
||||
# changing this signature would have decided it silently.
|
||||
# Door A and Door B's unfaceted path keep the default; which profile a
|
||||
# caller should own is a separate question, and answering it by changing
|
||||
# this signature would have decided it silently.
|
||||
#
|
||||
# `facets` also decides what "already present" MEANS, and the split is not a
|
||||
# convenience. A flat entry carries a label and a target, both stable, so it
|
||||
# can never disagree with the file it points at and returning early is
|
||||
# exactly right — a hand-edited label survives. An entry carrying the
|
||||
# concept's FACTS can go stale, and an index that contradicts the bundle it
|
||||
# indexes is worse than one that says nothing: the consumer reads the index
|
||||
# and stops there. So a faceted entry for a target already present is
|
||||
# REFRESHED in place rather than skipped.
|
||||
#
|
||||
# Additive by construction: with `facets=None` nothing below the early
|
||||
# return runs, so every unfaceted caller emits the bytes it always did.
|
||||
index_path = safe_resolve(bundle_dir, profile.index.name)
|
||||
body = index_path.read_bytes().decode("utf-8")
|
||||
if f"]({target_name})" in body:
|
||||
if facets is None:
|
||||
return
|
||||
_refresh_index_entry(index_path, target_name, label, facets, profile=profile)
|
||||
return
|
||||
# An empty index needs no separator: Door A always seeds its index with
|
||||
# bundle_summary first, but Door B has no summary to invent, so its index
|
||||
# starts empty and must not open with a blank line.
|
||||
prefix = body if (body == "" or body.endswith("\n")) else body + "\n"
|
||||
line = profile.index.render_link(label, target_name)
|
||||
line = profile.index.render_link(label, target_name, facets=facets)
|
||||
index_path.write_bytes(f"{prefix}{line}\n".encode())
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue