feat(inbox): point every concept at the document it came from, with a locator per format
A concept named its source file by basename and, when segmented, carried a
`source_offset` into the text THIS LIBRARY extracted. Following that pointer
needed the corpus directory, the extractor and its exact transitive version --
none of which the bundle carries. Hand-walked on a real K2 concept: six steps,
four of them requiring knowledge from outside the bundle, to learn that a
requirement sits on pages 12-13 of a 20-page document.
The address is spec's: `sources: [{ resource, title }]`, where `resource` is
the dropped file's inbox-relative path (SPEC v0.2 5.1:303-306 -- "an absolute
URL, a bundle-relative path, or a path into a `references/` subdirectory").
The locator is ours, and it has to be: 5.1 has no field for a place within a
resource, and the pinned guard (1.3.0) rejects every route to putting one
inside a `sources` entry -- a non-allowlisted key by name, a nested flow list
as "scalar leaves only", and quoting as an unsupported form. So the locator is
top-level keys shaped like `source_offset`, and a path carrying a flow
terminator is refused fail-fast rather than mangled.
The unit table is built AT EXTRACTION, where the extracted text and the
original's structure are known to agree: pdf -> `source_pages` from
pdfplumber's own page numbers (a page that yielded no text does not renumber
the ones after it), xlsx -> `source_sheet` + `source_rows`, everything else ->
`source_lines`. `source_offset` stays.
Two measurements changed the design before it shipped. A `paragraphs` key for
docx would name a number the document does not have: `<w:p>` counts of
108/27/65/176/57 against converted-markdown lines of 75/33/67/144/63, not one
pair agreeing -- so the key is `source_lines` and says what it indexes. And an
empty spreadsheet row renders exactly like a table separator: the content-based
rule ate 8 empty rows on the K2 price sheet and reported its last row as 92
against a workbook that says 100. The separator is now found by position, and
`tomrad.xlsx` keeps that red.
One profile moves. `provenance` is a policy object, `None` everywhere but
`SEGMENTED_OKF_V0_2`; the other five shipped profiles are byte-identical.
K2 rebuilt from a frozen src copy: 629 concepts, 1108 files, name set identical,
0 ids moved, 479 files byte-identical, 629 changed and 0 lines removed anywhere.
629/629 now carry an address and a locator. New ref
`sha256-tree:665563a2f74423fcbcc8e4f0b0954ee73b73985ac0418de4f6987bd162a1f7c8`;
`2f82fcfe...` is stale. The pre-pass payload does not grow by one byte
(209 092 B before and after, 18 changed lines: the ref and eight per-concept
digests) -- because an excerpt carries the body, not the frontmatter, which is
also why the consumer still cannot cite "file X page 12" from a payload alone.
Report: docs/2026-09-08-proveniens-k2.md. 1339 tests, ruff and mypy clean.
Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
d3bfe92acd
commit
b6a8c8bd89
16 changed files with 1301 additions and 26 deletions
|
|
@ -32,6 +32,7 @@ import warnings
|
|||
import zipfile
|
||||
from xml.etree import ElementTree
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -236,6 +237,45 @@ def _extra_missing(suffix: str) -> ExtractionError:
|
|||
)
|
||||
|
||||
|
||||
# How `_extract_pdf` joins its pages, named because the locator below has to
|
||||
# reproduce the exact same arithmetic to turn a character offset back into a
|
||||
# page number. Two constants that must agree, written once.
|
||||
_PDF_PAGE_SEPARATOR = "\n\n"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _pdf_pages(data: bytes) -> tuple[tuple[int, str], ...]:
|
||||
"""Every page that produced text, as `(page number, text)`, in page order.
|
||||
|
||||
The page NUMBER is 1-based and comes from the document, so a page that
|
||||
yielded nothing removes itself from the sequence without renumbering the
|
||||
ones after it -- which is the difference between "the third page that
|
||||
produced text" and "page 3", and the whole reason a locator is worth
|
||||
writing down.
|
||||
|
||||
Memoised on the bytes with room for exactly one document: extraction and
|
||||
location are two calls about the same file, back to back, and parsing it
|
||||
twice would double the PDF cost of every corpus run for nothing. Anything
|
||||
larger would hold whole documents in memory for no gain, since the caller
|
||||
never returns to an earlier file.
|
||||
"""
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError as exc:
|
||||
raise _extra_missing(".pdf") from exc
|
||||
|
||||
try:
|
||||
with pdfplumber.open(io.BytesIO(data)) as pdf:
|
||||
pages = [(page.extract_text() or "").rstrip() for page in pdf.pages]
|
||||
except ExtractionError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - third-party parser, wrapped never leaked
|
||||
raise ExtractionError(
|
||||
f"the PDF parser failed on this file: {exc}", code="extractor_pdf_error"
|
||||
) from exc
|
||||
return tuple((number, page) for number, page in enumerate(pages, start=1) if page)
|
||||
|
||||
|
||||
def _extract_pdf(data: bytes) -> str:
|
||||
"""`pdf`: page text via `pdfplumber`, in page order, pages separated by a
|
||||
blank line.
|
||||
|
|
@ -252,22 +292,8 @@ def _extract_pdf(data: bytes) -> str:
|
|||
and pymupdf each emit all labels then all values. Re-pairing those is
|
||||
guesswork, and in a requirements document a wrong pairing looks right.
|
||||
"""
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError as exc:
|
||||
raise _extra_missing(".pdf") from exc
|
||||
|
||||
try:
|
||||
with pdfplumber.open(io.BytesIO(data)) as pdf:
|
||||
pages = [(page.extract_text() or "").rstrip() for page in pdf.pages]
|
||||
except ExtractionError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - third-party parser, wrapped never leaked
|
||||
raise ExtractionError(
|
||||
f"the PDF parser failed on this file: {exc}", code="extractor_pdf_error"
|
||||
) from exc
|
||||
|
||||
text = "\n\n".join(page for page in pages if page)
|
||||
pages = _pdf_pages(data)
|
||||
text = _PDF_PAGE_SEPARATOR.join(page for _, page in pages)
|
||||
if not text:
|
||||
raise ExtractionError(
|
||||
"the PDF yielded no text on any page; a scanned or image-only "
|
||||
|
|
@ -420,6 +446,195 @@ _OPTIONAL_EXTRACTORS: dict[str, Callable[[bytes], str]] = {
|
|||
}
|
||||
|
||||
|
||||
# --- provenance: a character range of the extracted text -> a place in the
|
||||
# original document ---------------------------------------------------------
|
||||
#
|
||||
# `source_offset` alone is a position in OUR extraction, so following it back
|
||||
# needs the corpus directory, the extractor and its exact version -- none of
|
||||
# which a bundle carries. A unit table is that mapping, saved AT EXTRACTION
|
||||
# where the two are known to agree, rather than guessed afterwards from text
|
||||
# whose page breaks are gone.
|
||||
#
|
||||
# THE UNIT IS PER FORMAT AND IS NAMED, never assumed:
|
||||
#
|
||||
# pages a PDF page number, from the document itself.
|
||||
# rows a spreadsheet row, within the sheet named by `scope_of`.
|
||||
# lines a line of the EXTRACTED text. For `md`/`txt` that text is the
|
||||
# dropped file, so the number is the original's own line; for the
|
||||
# converted formats it is not, and the key says `lines` rather than
|
||||
# `paragraphs` for exactly that reason. Measured on the five K2
|
||||
# `.docx` documents: `<w:p>` counts 108/27/65/176/57 against
|
||||
# converted-markdown line counts 75/33/67/144/63 -- not one pair
|
||||
# agrees, so a `paragraphs` key would name a number the original does
|
||||
# not have.
|
||||
#
|
||||
# The heading a spreadsheet's sheet becomes, as the converter writes it:
|
||||
# `## <sheet name> {#sheet-<n>}`. Anchored to the line start so a pipe cell
|
||||
# containing a `#` cannot be read as a sheet.
|
||||
_SHEET_HEADING = re.compile(r"^#{1,6} (?P<name>.*?) \{#sheet-\d+\}$")
|
||||
|
||||
# A line the converter wrote as part of a pipe table. Whether one of them is
|
||||
# the table's SEPARATOR is decided by POSITION, never by content: an empty
|
||||
# spreadsheet row renders as `| | |` and a separator as `|----|----|`, and
|
||||
# every content rule that tells those apart also swallows a data row that
|
||||
# happens to hold only dashes. Measured on the K2 price sheet: a content rule
|
||||
# ate 8 empty rows and reported the sheet's last row as 92 against a workbook
|
||||
# that says 100.
|
||||
_TABLE_LINE = "|"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceUnits:
|
||||
"""Where in the ORIGINAL each stretch of the extracted text came from.
|
||||
|
||||
`starts[i]` is the character offset in the extracted text at which unit
|
||||
`numbers[i]` begins, and `scopes[i]` is the sheet that unit belongs to (or
|
||||
`None` for a format that has no sheets). The three tuples are parallel and
|
||||
`starts` ascends, which is what lets `covering` be a bisection rather than
|
||||
a scan.
|
||||
|
||||
`numbers` is separate from the index on purpose. A PDF page that yielded no
|
||||
text is not in this table, and a pipe table's separator line is a row of
|
||||
nothing -- in both cases the position in the table and the number in the
|
||||
original have already parted company, and an index standing in for a number
|
||||
is the off-by-one this whole object exists to prevent.
|
||||
"""
|
||||
|
||||
unit: str
|
||||
starts: tuple[int, ...]
|
||||
numbers: tuple[int, ...]
|
||||
scopes: tuple[str | None, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.starts) != len(self.numbers):
|
||||
raise ValueError("a unit table needs one number per start offset")
|
||||
if self.scopes and len(self.scopes) != len(self.starts):
|
||||
raise ValueError("a unit table needs one scope per start offset, or none at all")
|
||||
|
||||
def _index(self, offset: int) -> int:
|
||||
"""The table row covering `offset`, clamped to the table's own ends."""
|
||||
low, high = 0, len(self.starts) - 1
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if self.starts[middle] <= offset:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
return low
|
||||
|
||||
def covering(self, start: int, end: int) -> tuple[int, int]:
|
||||
"""The first and last original unit the half-open `[start, end)` touches.
|
||||
|
||||
`end` is exclusive, so a range ending exactly where the next unit
|
||||
begins does not claim that unit -- a segment that stops at a page
|
||||
boundary is on the page it was written on.
|
||||
"""
|
||||
if not self.starts:
|
||||
raise ValueError("an empty unit table locates nothing")
|
||||
first = self._index(start)
|
||||
last = self._index(max(start, end - 1))
|
||||
return self.numbers[first], self.numbers[last]
|
||||
|
||||
def scope_of(self, offset: int) -> str | None:
|
||||
"""The sheet `offset` falls in, or `None` for a format without sheets."""
|
||||
if not self.scopes:
|
||||
return None
|
||||
return self.scopes[self._index(offset)]
|
||||
|
||||
def scopes_covering(self, start: int, end: int) -> tuple[str | None, ...]:
|
||||
"""Every distinct scope the range touches, in order, without repeats."""
|
||||
if not self.scopes:
|
||||
return ()
|
||||
first = self._index(start)
|
||||
last = self._index(max(start, end - 1))
|
||||
seen: list[str | None] = []
|
||||
for scope in self.scopes[first : last + 1]:
|
||||
if not seen or seen[-1] != scope:
|
||||
seen.append(scope)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
def _line_units(text: str) -> SourceUnits:
|
||||
starts: list[int] = []
|
||||
offset = 0
|
||||
for line in text.split("\n"):
|
||||
starts.append(offset)
|
||||
offset += len(line) + 1
|
||||
return SourceUnits("lines", tuple(starts), tuple(range(1, len(starts) + 1)))
|
||||
|
||||
|
||||
def _pdf_units(data: bytes) -> SourceUnits:
|
||||
starts: list[int] = []
|
||||
numbers: list[int] = []
|
||||
offset = 0
|
||||
for number, page in _pdf_pages(data):
|
||||
starts.append(offset)
|
||||
numbers.append(number)
|
||||
offset += len(page) + len(_PDF_PAGE_SEPARATOR)
|
||||
return SourceUnits("pages", tuple(starts), tuple(numbers))
|
||||
|
||||
|
||||
def _spreadsheet_units(text: str) -> SourceUnits | None:
|
||||
"""Sheet and row for a converted spreadsheet, or `None` if it is not one.
|
||||
|
||||
The converter writes one heading per sheet and then one pipe-table line per
|
||||
source row, with a separator line after the first. Row numbering therefore
|
||||
restarts at every heading and skips that one line by POSITION.
|
||||
|
||||
The row number is the ORIGINAL sheet's, and that holds exactly as far as
|
||||
one converted line per `<row>` element holds. Measured on the two K2
|
||||
spreadsheets and both fixtures: 39 rows for 39, 100 for 100, 4 for 4, 6 for
|
||||
6 and 3 for 3 -- every one contiguous from row 1. A sheet whose XML omits a
|
||||
row entirely would number from the converted table instead, and nothing
|
||||
here can see that.
|
||||
"""
|
||||
starts: list[int] = []
|
||||
numbers: list[int] = []
|
||||
scopes: list[str | None] = []
|
||||
sheet: str | None = None
|
||||
seen = 0
|
||||
offset = 0
|
||||
for line in text.split("\n"):
|
||||
heading = _SHEET_HEADING.match(line)
|
||||
if heading is not None:
|
||||
sheet = heading.group("name")
|
||||
seen = 0
|
||||
elif sheet is not None and line.startswith(_TABLE_LINE):
|
||||
seen += 1
|
||||
# The SECOND table line of a sheet is the separator the converter
|
||||
# writes under the header, and it is a row of no spreadsheet. Every
|
||||
# line after it is one row further on than its position suggests.
|
||||
if seen != 2:
|
||||
starts.append(offset)
|
||||
numbers.append(seen if seen == 1 else seen - 1)
|
||||
scopes.append(sheet)
|
||||
offset += len(line) + 1
|
||||
if not starts:
|
||||
return None
|
||||
return SourceUnits("rows", tuple(starts), tuple(numbers), tuple(scopes))
|
||||
|
||||
|
||||
def source_units(filename: str, data: bytes, text: str) -> SourceUnits | None:
|
||||
"""The unit table for one dropped file, or `None` when it has none.
|
||||
|
||||
`text` must be what `extract_text` returned for these exact bytes: the
|
||||
table indexes that string, and a table built against a different rendering
|
||||
would point a consumer at the wrong place with full confidence.
|
||||
|
||||
`None` is a measurement, not a failure -- a spreadsheet the converter wrote
|
||||
no table for has no rows to name, and the caller writes the address without
|
||||
a locator rather than inventing one.
|
||||
"""
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
return _pdf_units(data)
|
||||
if suffix == ".xlsx":
|
||||
return _spreadsheet_units(text)
|
||||
if suffix in _CORE_EXTRACTORS or suffix in _PANDOC_FORMATS:
|
||||
return _line_units(text)
|
||||
return None
|
||||
|
||||
|
||||
def extract_text(
|
||||
filename: str, data: bytes, *, renderer: Callable[[str], str] | None = None
|
||||
) -> str:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from dataclasses import dataclass, replace
|
|||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from .errors import IngestError, MaterializationError, SegmentationError, SourceError
|
||||
from .extract import extract_text
|
||||
from .extract import SourceUnits, extract_text, source_units
|
||||
from .materialize import (
|
||||
_render_root_frontmatter,
|
||||
check_filename_length,
|
||||
|
|
@ -37,7 +37,7 @@ from .materialize import (
|
|||
validate_ingested_at,
|
||||
write_bytes,
|
||||
)
|
||||
from .profiles import DEFAULT, BundleProfile, IndexEntry
|
||||
from .profiles import DEFAULT, BundleProfile, IndexEntry, ProvenancePolicy
|
||||
from .segmentation import (
|
||||
SegmentationPlan,
|
||||
SegmentEntry,
|
||||
|
|
@ -111,6 +111,8 @@ def render_inbox_concept(
|
|||
structure: DocumentStructure | None = None,
|
||||
segment: SegmentEntry | None = None,
|
||||
bundle_id: str | None = None,
|
||||
units: SourceUnits | None = None,
|
||||
span: tuple[int, int] | None = None,
|
||||
) -> str:
|
||||
"""Frame extracted text as an inbox concept file with its provenance layer.
|
||||
|
||||
|
|
@ -119,6 +121,12 @@ def render_inbox_concept(
|
|||
`ingested_at`, on the reserved verdict layer, and on a title or
|
||||
`source_file` that would break an index link or inject frontmatter lines.
|
||||
|
||||
`units` and `span` carry the provenance locator and are read ONLY when the
|
||||
profile declares that capability. `span` defaults to the segment's own when
|
||||
the concept is segmented; a whole-document concept must supply it, because
|
||||
the text arriving here is the SANITIZED text and its length is not
|
||||
necessarily the extracted text's.
|
||||
|
||||
`segment` and `bundle_id` carry the 1-to-N identity layer and are read ONLY
|
||||
when the profile declares the segmentation capability. A concept the plan
|
||||
does not cover keeps today's rule verbatim, and the four shipped profiles
|
||||
|
|
@ -208,9 +216,78 @@ def render_inbox_concept(
|
|||
frontmatter["adjudicated_by"] = verdict.adjudicated_by
|
||||
frontmatter["adjudicated_at"] = verdict.adjudicated_at
|
||||
frontmatter["adjudication_dwell_s"] = str(verdict.adjudication_dwell_s)
|
||||
if profile.provenance is not None:
|
||||
# A segment's own span is the default, but only where the segment is
|
||||
# being READ -- `segmented` is the same discriminator the identity
|
||||
# layer above uses, so a profile with provenance and no segmentation
|
||||
# cannot silently locate by a span it is ignoring everywhere else.
|
||||
located = span
|
||||
if located is None and segmented:
|
||||
assert segment is not None
|
||||
located = segment.span
|
||||
frontmatter.update(
|
||||
_provenance_frontmatter(
|
||||
profile.provenance,
|
||||
source_file=source_file,
|
||||
units=units,
|
||||
span=located,
|
||||
)
|
||||
)
|
||||
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
||||
|
||||
|
||||
# The characters that would end a YAML flow mapping early, so a path carrying
|
||||
# one would produce a `sources` list that parses as something other than what
|
||||
# was written. The guard refuses a quoted scalar inside a flow mapping (1.3.0,
|
||||
# measured), so escaping is not on the table -- validation is.
|
||||
_FLOW_TERMINATORS = ",{}[]"
|
||||
|
||||
|
||||
def _provenance_frontmatter(
|
||||
policy: ProvenancePolicy,
|
||||
*,
|
||||
source_file: str,
|
||||
units: SourceUnits | None,
|
||||
span: tuple[int, int] | None,
|
||||
) -> dict[str, str]:
|
||||
"""The address, and the locator when one is available.
|
||||
|
||||
The address is written whether or not a locator is: `sources` answers
|
||||
"which document", the locator answers "where in it", and a consumer is owed
|
||||
the first even when the second cannot be computed.
|
||||
"""
|
||||
bad = [char for char in _FLOW_TERMINATORS if char in source_file]
|
||||
if bad:
|
||||
raise MaterializationError(
|
||||
f"source_file {source_file!r} contains {bad[0]!r}, which would end the "
|
||||
"`sources` flow mapping early; this profile writes an address a "
|
||||
"consumer can follow, and a path it cannot express is refused rather "
|
||||
"than mangled",
|
||||
code="inbox_source_file_unaddressable",
|
||||
)
|
||||
values = {
|
||||
policy.sources_key: (
|
||||
f"[{{ resource: {source_file}, title: {PurePosixPath(source_file).name} }}]"
|
||||
)
|
||||
}
|
||||
if units is None or span is None:
|
||||
return values
|
||||
first, last = units.covering(*span)
|
||||
if units.unit == "pages":
|
||||
values[policy.pages_key] = _render_flow_list([str(first), str(last)])
|
||||
elif units.unit == "rows":
|
||||
scopes = units.scopes_covering(*span)
|
||||
# A row number means nothing until a sheet is named, so a range that
|
||||
# crosses sheets gets neither key. An absence, never a first-sheet
|
||||
# guess: a guess here reads exactly like a fact.
|
||||
if len(scopes) == 1 and scopes[0] is not None:
|
||||
values[policy.sheet_key] = scopes[0]
|
||||
values[policy.rows_key] = _render_flow_list([str(first), str(last)])
|
||||
else:
|
||||
values[policy.lines_key] = _render_flow_list([str(first), str(last)])
|
||||
return values
|
||||
|
||||
|
||||
# --- the guard seam -------------------------------------------------------
|
||||
|
||||
# The guard's non-blocking floor. `Disposition` is a `str, Enum` in
|
||||
|
|
@ -542,6 +619,7 @@ def _render_segments(
|
|||
profile: BundleProfile,
|
||||
bundle_id: str,
|
||||
source_file: str,
|
||||
units: SourceUnits | None,
|
||||
) -> BlockedFile | None:
|
||||
"""Render every segment, or refuse the WHOLE document.
|
||||
|
||||
|
|
@ -607,6 +685,7 @@ def _render_segments(
|
|||
structure=structure,
|
||||
segment=entry,
|
||||
bundle_id=bundle_id,
|
||||
units=units,
|
||||
),
|
||||
decision.reasons,
|
||||
)
|
||||
|
|
@ -879,6 +958,16 @@ def process_inbox(
|
|||
source_bytes,
|
||||
renderer=_resolve_renderer(profile, path.name),
|
||||
)
|
||||
# Computed from the SAME text the plan's offsets index, so the
|
||||
# locator and the offset can never disagree about which rendering
|
||||
# they describe. `None` when the profile names no provenance:
|
||||
# building a unit table nobody writes would re-parse every PDF for
|
||||
# a key that is never emitted.
|
||||
units = (
|
||||
source_units(source_name(path), source_bytes, text)
|
||||
if profile.provenance is not None
|
||||
else None
|
||||
)
|
||||
covering = _plan_covering(plans, source_bytes)
|
||||
if covering is not None:
|
||||
blocked = _render_segments(
|
||||
|
|
@ -891,6 +980,7 @@ def process_inbox(
|
|||
profile=profile,
|
||||
bundle_id=(root_frontmatter_values or {})[_bundle_id_key(profile)],
|
||||
source_file=source_name(path),
|
||||
units=units,
|
||||
)
|
||||
if blocked is not None:
|
||||
if blocked.disposition == _DISPOSITION_QUARANTINE:
|
||||
|
|
@ -937,6 +1027,12 @@ def process_inbox(
|
|||
ingested_at=ingested_at,
|
||||
profile=profile,
|
||||
structure=structure,
|
||||
units=units,
|
||||
# The EXTRACTED text's span, never the sanitized
|
||||
# text's: the unit table indexes the former, and a
|
||||
# gate that removed a character would shift every
|
||||
# unit boundary after it.
|
||||
span=(0, len(text)),
|
||||
),
|
||||
decision.reasons,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -905,6 +905,39 @@ class SegmentationPolicy:
|
|||
adjudication_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenancePolicy:
|
||||
"""Whether a concept carries an address back to the document it came from.
|
||||
|
||||
Two layers, and the split is load-bearing rather than tidy.
|
||||
|
||||
The ADDRESS is SPEC's. §5.1:303-306 makes `sources[].resource` REQUIRED
|
||||
within an entry and lets it be "an absolute URL, a bundle-relative path, or
|
||||
a path into a `references/` subdirectory (§6)" -- which is exactly what a
|
||||
dropped file's inbox-relative path is. No new key is invented where the
|
||||
spec already has one.
|
||||
|
||||
The LOCATOR is OURS, and it has to be. §5.1 has no field for a page, a
|
||||
sheet row or a line, and the guard's frontmatter grammar (1.3.0, measured)
|
||||
refuses every route to putting one inside a `sources` entry: a key outside
|
||||
its `sources` allowlist is rejected by name, and a nested flow list is
|
||||
rejected as "a flow mapping admits scalar leaves only". So a locator inside
|
||||
the entry would be a bundle we emit and could never read back through Door
|
||||
C. Top-level keys, in the shape `source_offset` already uses.
|
||||
|
||||
Every field NAMES a key and none supplies a value, like every other policy
|
||||
here. The presence of this object IS the capability: a profile that names
|
||||
no provenance writes none, which is what keeps the five shipped profiles
|
||||
that do not name it byte-identical.
|
||||
"""
|
||||
|
||||
sources_key: str = "sources"
|
||||
pages_key: str = "source_pages"
|
||||
sheet_key: str = "source_sheet"
|
||||
rows_key: str = "source_rows"
|
||||
lines_key: str = "source_lines"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleProfile:
|
||||
"""One bundle contract: types, frontmatter, filenames, index."""
|
||||
|
|
@ -926,6 +959,11 @@ class BundleProfile:
|
|||
# package; writing one is a Non-Goal and is named here as unassigned so the
|
||||
# absence is deliberate rather than an oversight.
|
||||
renderers: Mapping[str, str] | None = None
|
||||
# Defaulted to `None` for the same reason `segmentation` is: `None` is not
|
||||
# "provenance off", it is the profile not having the capability, which is
|
||||
# what the door's `is not None` check reads. Five of the six shipped
|
||||
# profiles leave it unset and keep their bytes.
|
||||
provenance: ProvenancePolicy | None = None
|
||||
|
||||
|
||||
# The ingest-spec + Phase 2 contract. Every value here was a constant in
|
||||
|
|
@ -1281,6 +1319,15 @@ SEGMENTED_OKF_V0_2 = BundleProfile(
|
|||
# attribute is typed `| None`, and the equality is asserted in the suite so
|
||||
# this stays a fresh copy of the same policy plus the discriminator.
|
||||
segmentation=SegmentationPolicy(adjudication_key="adjudication"),
|
||||
# O3, and set on THIS profile alone. `sources` is a v0.2 key, so a profile
|
||||
# stating v0.1 must not name it; `DEFAULT` and `STRICT_V1` state contracts
|
||||
# owned in other repositories, so adding a key to either from here would be
|
||||
# this repository editing someone else's contract (O2); and `OKF_V0_2` is
|
||||
# Door A's, where `sources` is already written from the manifest. What is
|
||||
# left is the segmented v0.2 profile -- the one whose concepts come from a
|
||||
# dropped binary document and therefore the only one with an original to
|
||||
# point at.
|
||||
provenance=ProvenancePolicy(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue