portfolio-optimiser/src/portfolio_optimiser/ingest.py
Kjell Tore Guttormsen c255662802 feat(ingest): P2/S1.b — innholdsgaten står, rundt materialize og ikke i den
Planens §3 sa at `ingest.materialize` er repoets ENE skrivepunkt på Door A, og
det premisset ble felt av måling FØR bygging: `materialize` er en ren delegasjon
til pinnet llm_ingestion_okf v0.3.2s `materialize_bundle`, som stager i minnet og
utfører sin egen disk-fase. Det finnes ingen callback mellom de to, så en gate
plassert der kunne bare kjørt ETTER at bytene landet — en opprydding, ikke en gate.

Sømmen ble i stedet kopier bundelen → materialiser inn i kopien → skann det som
ble generert → publiser eller forkast. Kopien er bærende, ikke bekvemmelighet:
bibliotekets §3 eierskaps-skann, kollisjonsgaten mot kuratert innhold og §6
index-merge leser alle den EKSISTERENDE bundelen. Staging i tom katalog mister
alle tre og publiserer en bundle uten kuraterte naboer — datatap forkledd som
sikkerhetsfiks.

De fire §4-beslutningene, tatt og målt: (1) ingen av guardens to preset —
Origin.EXTERNAL/AUTOMATIC, fordi trust_for utleder policy fra origin alene og
PRESET_USER_UPLOAD bærer en quarantine-semantikk Door A ikke har; (2) utfall per
BUNDLE, diagnostikk per DOKUMENT — delvis publisering ville etterlatt bundle +
index som svarer til intet manifest, men import_bundle itererer forbi første
avvisning; (3) Report til log.md, aldri konsept-frontmatter, der fire golden-suiter
pinner bytene; (4) mypy-override OG adapter, siden override alene gjør sømmen
type-blind i stedet for type-sikker.

`materialize` forblir ugatet med vilje — goldenene pinner den, og en kaller som
vil ha gaten ber om den ved navn.

Fem mutasjoner alle røde + grønn kontroll (hele suiten, ~120 s hver): detach
gaten · la den fyre ETTER publisering · Origin.INTERNAL · tom staging-katalog ·
rapporter kun første avvisning.

Målingen felte en VAKUØS test først: en hard injeksjon scorer fail_secure under
BEGGE trust-tierene, så Origin.INTERNAL-mutasjonen lot alle tre avvisningstestene
stå grønne — beslutning 1 så dekket ut uten å være testet. Båndet der tieren
faktisk avgjør er høy-entropi-innhold (quarantine_review vs warn), og testen ble
skrevet mot nøyaktig det før mutasjonen ble re-målt. Mutasjon 4 ble på sin side
felt av KUN én test; 809 andre merket ikke at bundle-kopien forsvant.

Laveste disposition er `warn`, ikke `allow` — `allow` finnes ikke i guarden. En
gate skrevet mot == allow ville avvist hvert dokument som noensinne ingestes.

Kriterium 5 står: demo-stdout er byte-identisk med tests/golden/demo-transcript.stdout,
målt både i suiten og ved eksplisitt kjøring. shared/ er urørt.

801 -> 810 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDu94KoyxAmhJsG2n63X8Q
2026-08-09 22:58:00 +02:00

359 lines
17 KiB
Python

"""Door A ingest — the consumer seam over the shared ``llm-ingestion-okf`` library.
An addition IN FRONT of the loop (``shared/ingest-spec.md`` §1): a deterministic step that
couples the framework to a real data source and materializes the extract as an OKF knowledge
bundle, which the existing 8-step loop then consumes UNCHANGED. Zero model calls; no network
for the ``file``/``sql`` source types.
Since the library adoption (2026-07-20) the implementation IS the shared
``llm-ingestion-okf`` library (git-pinned to ``v0.3.2``). The spec in ``shared/ingest-spec.md``
remains the normative source — the library implements it, it does not replace it — and the
repo-local goldens under ``examples/`` remain the fasit (verified byte-exact across all three
source types, before and after the swap).
This module is the ONE place the repo touches the library for Door A. It is deliberately thin:
it re-exports the library's typed surface and keeps the historical ``materialize`` signature
(keyword-only ``ingested_at``, ``list[Path]`` return) so callers and tests bind to a stable
repo-local name rather than to the library's evolving one.
**Gating is the CALL SITE's responsibility (library README, "What is gated today: nothing").**
The library writes what it is given, so this module owns both gates Door A has:
- NETWORK (§8, no silent egress) — ``allow_network`` (default ``False``): an ``http`` source is
refused fail-fast unless a run explicitly opts in, so the manifest can never grant itself
network access.
- CONTENT (P2/S1.b) — :func:`materialize_gated` scans every concept an ingest run generates,
with ``llm-ingestion-guard`` (git-pinned to ``v0.3.4``), BEFORE any of it reaches the bundle.
:func:`materialize` is the UNGATED form and stays that way: the four golden suites pin its bytes,
and a caller who wants the gate asks for it by name. Nothing on the run path calls either — Door
A is not on the 8-step loop's path (``run.py`` / ``simulation.py`` do not import this module).
MAF-free (D7-portable), like the rest of the context seam: the library has zero runtime
dependencies and imports no ``agent_framework`` / ``mcp``. Guarded by
``tests/test_ingest_loadbearing.py::test_ingest_module_is_maf_free_and_context_layer_pure``.
"""
from __future__ import annotations
import hashlib
import shutil
import socket
import tempfile
from pathlib import Path
from typing import Any
from llm_ingestion_okf import (
Extraction,
FileSource,
HttpSource,
IngestError,
IngestResult,
Manifest,
ManifestError,
MaterializationError,
NetworkGateError,
RenderError,
SourceError,
SqlSource,
materialize_bundle,
)
from llm_ingestion_okf import (
load_manifest as _load_manifest,
)
from llm_ingestion_okf.connectors import (
HttpGet,
read_csv,
read_http,
read_sql,
)
from llm_ingestion_okf.manifest import generated_filename
from llm_ingestion_okf.render import render_fenced_block, render_table
from llm_ingestion_guard.okf import (
Channel,
Origin,
format_log_entry,
import_bundle,
)
# Two historical PRIVATE names the repo's existing tests bind to, re-exported so those bindings
# survive the adoption unchanged. `_urllib_get` backs an identity assertion that the default http
# transport is the real socket path rather than a stub (tests/test_ingest_http.py), and is the
# delegate `timeout_get` wraps below; `_sql_value_to_text` is exercised directly for the §5
# bool/BLOB refusals (tests/test_ingest_sql.py). Not in `__all__` — not part of this contract.
from llm_ingestion_okf.connectors import urllib_get as _urllib_get
from llm_ingestion_okf.render import sql_value_to_text as _sql_value_to_text # noqa: F401
#: The repo's historical name for the §4 top-level model. The library calls it ``Manifest`` and
#: carries the version in the ``manifest_version`` field (still ``1``); the alias keeps the
#: repo-local name stable for callers that bound to it before the library adoption.
ManifestV1 = Manifest
#: The wall-clock bound applied to the default http transport (S2.4). A source that accepts a
#: connection and then never answers must not be able to hang a run: the repo requires stop
#: criteria and budget caps at startup precisely so nothing runs unbounded, and an untimed socket
#: is the same failure wearing a different hat. Chosen to match the MCP transport's own
#: `timeout_seconds` default so both transports in the `http` family fail on the same clock.
HTTP_TIMEOUT_SECONDS = 30.0
__all__ = [
"HTTP_TIMEOUT_SECONDS",
"Channel",
"ContentGateRefused",
"Extraction",
"FileSource",
"HttpGet",
"HttpSource",
"IngestError",
"IngestResult",
"Manifest",
"ManifestError",
"ManifestV1",
"MaterializationError",
"NetworkGateError",
"Origin",
"RenderError",
"SourceError",
"SqlSource",
"generated_filename",
"load_manifest",
"materialize",
"materialize_bundle",
"materialize_gated",
"read_csv",
"read_http",
"read_sql",
"render_fenced_block",
"render_table",
"timeout_get",
]
def timeout_get(get: HttpGet, *, timeout: float = HTTP_TIMEOUT_SECONDS) -> HttpGet:
"""Wrap an http transport so its socket operations are time-bounded (S2.4).
The library's ``urllib_get`` is the only socket path, and it invokes the stdlib opener with no
``timeout=`` argument. urllib's documented fallback in that case is the process-wide default
socket timeout — ``None`` unless set — so an unanswered connection blocks forever. Scoping
``socket.setdefaulttimeout`` around the delegate call therefore bounds the real transport
WITHOUT opening a second socket path and WITHOUT re-implementing the credential header, which
is what S2.4 asks for: the fix belongs in front of the pinned library, not inside it.
The previous default is restored in a ``finally``, so a transport failure cannot leak the
bound onto unrelated sockets.
KNOWN CAVEAT — the default socket timeout is PROCESS-global, not per-call. Under
``concurrency=k`` the runner is asyncio on a single thread, so this scoping holds. If
``read_http`` is ever driven from a thread-pool executor, this is NOT thread-safe and the
bound has to move to a per-call timeout argument on the opener — i.e. to owning a socket
path in this module, which is exactly what this wrapper exists to avoid.
"""
def bounded(url: str, credential: str | None) -> str:
previous = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
try:
return get(url, credential)
finally:
socket.setdefaulttimeout(previous)
return bounded
def load_manifest(path: str | Path) -> tuple[Manifest, str]:
"""Validate a manifest fail-fast, BEFORE any source access (§4, §9).
Returns the validated manifest and the §5 provenance stamp ``{stem}@{hash16}`` — the first
16 hex chars of SHA-256 over the manifest file's RAW bytes, so every generated file points
at the exact manifest version that produced it. Raises ``ManifestError`` without touching
any source.
The two-value return is the repo-local shape: the library returns the manifest alone and
mints the stamp INSIDE ``materialize_bundle``, exposing no stamp helper (v0.3.2). The
formula is therefore restated here, which is the one place this adapter is not purely
delegating — so it is pinned by
``tests/test_ingest_library_seam.py::test_adapter_stamp_equals_library_stamp``, which
compares this value against the ``ingest_manifest`` the library actually writes. That test
goes RED if either side's formula drifts."""
manifest_path = Path(path)
manifest = _load_manifest(manifest_path)
stamp = f"{manifest_path.stem}@{hashlib.sha256(manifest_path.read_bytes()).hexdigest()[:16]}"
return manifest, stamp
def materialize(
manifest_path: str | Path,
bundle_dir: str | Path,
*,
ingested_at: str,
allow_network: bool = False,
http_get: HttpGet | None = None,
) -> list[Path]:
"""Materialize a manifest's extractions into an OKF bundle (§5), returning the generated
concept-file paths in extraction order.
The historical repo signature over the library's ``materialize_bundle``: ``ingested_at`` is
a REQUIRED keyword (no wall-clock default, stamped verbatim; mirrors the promotion gate's
timestamp rule), and the return is the ``written`` paths as a list rather than the
library's ``IngestResult``. ``allow_network`` (§8) is the per-run network opt-in — an
``http`` source is refused fail-fast unless it is set, so the manifest itself can never
grant network access. ``http_get`` optionally injects the transport seam; both are ignored
for ``file``/``sql`` sources. Call ``materialize_bundle`` directly when the stamp is
wanted alongside the paths.
The DEFAULT transport is time-bounded (S2.4): rather than letting the library resolve its own
untimed ``urllib_get``, this passes that same socket path wrapped in :func:`timeout_get`, so
an unanswered http source fails typed instead of hanging the run. An explicitly injected
``http_get`` is passed through UNWRAPPED — a caller-supplied transport (the MCP one fronts a
subprocess with its own ``timeout_seconds``; test stubs touch nothing) owns its own timeout
policy, and a process-global socket bound is not ours to impose on it."""
transport = http_get if http_get is not None else timeout_get(_urllib_get)
return list(
materialize_bundle(
Path(manifest_path),
Path(bundle_dir),
ingested_at,
allow_network=allow_network,
http_get=transport,
).written
)
class ContentGateRefused(IngestError):
"""The Door A content gate refused an ingest run; NOTHING was written (P2/S1.b).
Code: ``content_gate_refused``. ``rejected`` names every generated concept file that failed
the gate, in sorted order — the outcome is per BUNDLE, but the diagnostics are per DOCUMENT,
so one run surfaces every poisoned source rather than one per re-run.
"""
def __init__(self, message: str, *, rejected: tuple[str, ...]) -> None:
super().__init__(message, code="content_gate_refused")
self.rejected = rejected
#: The lowest disposition the guard emits — a CLEAN concept scores `warn`, not `allow`
#: (measured against v0.3.4; `Disposition` = warn < quarantine_review < fail_secure). The gate
#: therefore accepts `warn` and refuses everything above it. Written against an `allow` tier
#: that does not exist, the gate would refuse every document ever ingested.
_ACCEPTED_DISPOSITION = "warn"
def _stamp_line(concept: Any, ingested_at: str) -> str | None:
"""The concept's ``log.md`` line, or ``None`` when the guard produced no stamp.
A hard-rejected concept (bad path, unsafe frontmatter, non-https ``resource``) carries no
stamp — it never got far enough to be judged on content — so there is nothing to log; such a
run is refused anyway. Isolated into its own function so the coercion to ``str`` is a typed
boundary the type checker can see, rather than an ``Any`` flowing through a comprehension.
"""
stamp = concept.stamp
if stamp is None:
return None
return str(format_log_entry(stamp, timestamp=ingested_at))
def materialize_gated(
manifest_path: str | Path,
bundle_dir: str | Path,
*,
ingested_at: str,
allow_network: bool = False,
http_get: HttpGet | None = None,
origin: Origin = Origin.EXTERNAL,
channel: Channel = Channel.AUTOMATIC,
) -> list[Path]:
""":func:`materialize`, with every generated concept scanned BEFORE it reaches the bundle.
The Door A content gate. Same signature and same return as :func:`materialize`, plus the
guard's provenance pair. On refusal nothing is written and ``ContentGateRefused`` carries
the offending filenames.
**Why the gate is not inside** :func:`materialize` **(measured, and the reason the plan's
premise was wrong):** ``materialize`` delegates wholly to the pinned library's
``materialize_bundle``, which stages in memory and performs its own disk phase. No callback
exists between the two, so a gate placed there could only run after the bytes had landed —
a cleanup, not a gate. The seam is instead: stage into a COPY of the live bundle, scan what
was generated, then publish the copy or discard it.
**The copy is load-bearing, not a convenience.** The library's §3 ownership scan, its
collision gate against curated files, and its §6 index merge all read the EXISTING bundle
contents. Materializing into an empty temp directory would lose all three, and publishing
that on top of the live bundle would drop curated files and their index links — a data-loss
bug wearing a security fix's clothing.
**Trust follows origin, never channel** (the guard's own rule): Door A pulls external
sources named in a manifest, automatically, so the defaults are ``EXTERNAL`` / ``AUTOMATIC``
— ``UNTRUSTED``. This is deliberately NOT one of the guard's two ``Policy`` presets:
``PRESET_USER_UPLOAD`` additionally carries ``quarantine_default=True``, an upload
semantics Door A does not have, and ``PRESET_TRUSTED_SOURCE`` would grant a trust tier a
manifest-named external source has not earned.
**Only what this run generated is scanned.** Curated files already in the bundle are
human-authored and are not re-judged here; gating the bundle READ path is a separate
decision with a separate rationale, and it has not been taken.
Validation, ALWAYS — repair, NEVER: a refused document is not sanitised into the bundle,
it stays out of it (the ``write_concept_file`` / ``promote_verdict`` precedent).
"""
target = Path(bundle_dir)
with tempfile.TemporaryDirectory(prefix="po-ingest-gate-") as tmp:
# `resolve()` because the library returns resolved paths and macOS hands out `/var/...`
# temp dirs that are symlinks to `/private/var/...` — `relative_to` below compares the
# two literally, so an unresolved base raises ValueError on every macOS run.
staging = Path(tmp).resolve() / "bundle"
if target.exists():
shutil.copytree(target, staging)
written = materialize(
manifest_path,
staging,
ingested_at=ingested_at,
allow_network=allow_network,
http_get=http_get,
)
# Scan ONLY this run's output, keyed by the bundle-relative path the guard expects.
generated = {
path.relative_to(staging).as_posix(): path.read_text(encoding="utf-8")
for path in written
}
# ADAPTER at the untyped boundary (§4.4). The guard ships no `py.typed`, so everything
# below arrives as `Any`; the mypy override alone would make this seam type-BLIND, not
# type-safe. Each value read off a guard result object is therefore coerced to a
# concrete type HERE, so `Any` stops at this line instead of propagating into the
# module — and an upstream field rename fails loudly rather than type-checking happily.
verdicts: list[tuple[str, str, str | None]] = [
(str(concept.path), str(concept.disposition.value), _stamp_line(concept, ingested_at))
for concept in import_bundle(generated, origin=origin, channel=channel).concepts
]
rejected = tuple(
sorted(
path for path, disposition, _ in verdicts if disposition != _ACCEPTED_DISPOSITION
)
)
if rejected:
raise ContentGateRefused(
"Door A content gate refused "
f"{len(rejected)} of {len(verdicts)} generated concept(s): "
f"{', '.join(rejected)} — nothing was written to {target}",
rejected=rejected,
)
# Decision 3: the findings are recorded, never discarded — in `log.md` (OKF §7), the
# structural update log, NEVER in the concept frontmatter. The concept bytes are the
# pinned library's, and four golden suites pin them; a gate field injected there would
# break all four. `ingested_at` is stamped verbatim, mirroring the rest of Door A —
# `format_log_entry` keeps wall-clock out of the stamp itself.
log_lines = [line for _, _, line in verdicts if line is not None]
log_path = staging / "log.md"
existing = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
log_path.write_text(existing + "".join(f"{line}\n" for line in log_lines), "utf-8")
# Publish: the staged bundle IS the live bundle now. Replacing wholesale keeps the
# library's index merge intact — re-deriving it here would be a second copy of §6.
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(staging, target)
return [target / path.relative_to(staging) for path in written]