portfolio-optimiser/tests/test_ingest_library_seam.py
Kjell Tore Guttormsen b33ea00055 chore(deps): move the ingest library pin to v0.3.2 — as far as latest goes today
The pin had sat at v0.3.1 with STATE calling the hold "deliberate" and
recording no reason. Measured: no coord message ever announced v0.4.0 or
v0.5.0a* to this repo, so the hold was drift wearing a decision's clothes.

v0.3.2 is a pure fix (frontmatter and index labels emit verbatim; only
source_query is whitespace-collapsed, per ingest-spec §5), keeps
`dependencies = []`, and is green here: 668 passed.

WHY NOT FURTHER, both measured rather than assumed:

1. v0.4.0 introduces a REGRESSION that breaks our §6 removal path.
   Bisected v0.3.2 OK / v0.4.0 RED with a minimal repro: materialize a
   bundle, then re-materialize it with a CHANGED manifest, and the library
   no longer recognises its own stamp —

     MaterializationError: generated filename 'ingest-costs.md' collides
     with an existing file that does not carry the ingest stamp

   The stamp carries the manifest's name+hash (`ingest_manifest: m2@…`), so
   editing a manifest makes every file it previously wrote look curated.
   Re-ingesting the SAME manifest is fine, which is why fixtures miss it.
   It is `tests/test_ingest_loadbearing.py::test_reingest_with_active_
   removal_preserves_promoted_and_curated` that catches it. Reported
   upstream; not ours to fix.

2. Everything past v0.3.1 adds `llm-ingestion-guard>=0.2,<0.3` as a HARD
   runtime dependency (v0.3.1/v0.3.2: `dependencies = []`). That flips two
   documented invariants here — pyproject's "zero runtime deps" comment and
   the STATE marker line the guard repo reads machine-readably ("not a
   runtime dependency today"). An operator decision, not a version bump.

3. v0.5.0a2 is an alpha whose own CHANGELOG scopes it to a named pilot set
   — portfolio-optimiser-claude, the marketplace catalog, claude-code-llm-wiki
   — and says "do not pin this tag outside the pilot set", with the v0.2
   surface free to change without a deprecation cycle. This repo is not a
   pilot. Joining is llm-ingestion-okf's call, requested via coord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyAbxJoyypnLLUDcMvnKh8
2026-08-05 12:11:12 +02:00

127 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Load-bearing checks on the consumer seam over ``llm-ingestion-okf`` (adopted 2026-07-20).
Door A is no longer implemented here — ``src/portfolio_optimiser/ingest.py`` is a thin adapter
over the shared library, so the spec §4§6 rules are covered by the library's own suite plus the
repo's golden regressions. What is NOT covered by either is the seam itself: the places where the
adapter restates something instead of delegating, and the boundary claims the adapter's docstring
makes. Those are pinned here, because a docstring that no test can falsify is decoration.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
import pytest
from llm_ingestion_okf import NetworkGateError
from portfolio_optimiser import okf
from portfolio_optimiser.ingest import load_manifest, materialize, materialize_bundle
_INGESTED_AT = "2026-07-03T12:00:00Z"
_MANIFEST: dict[str, Any] = {
"manifest_version": 1,
"source": {"type": "file", "id": "prosjekt-arkiv", "root": "fixture"},
"bundle_summary": "Cost extracts from the project archive.",
"extractions": [
{
"id": "costs",
"title": "Project costs",
"query": "costs.csv",
"okf_type": "dataset",
"max_rows": 100,
}
],
}
def _project(tmp_path: Path, source: dict[str, Any] | None = None) -> tuple[Path, Path]:
fixture = tmp_path / "fixture"
fixture.mkdir()
(fixture / "costs.csv").write_bytes(b"item,cost_nok\nled-retrofit,120000\n")
data = json.loads(json.dumps(_MANIFEST))
if source is not None:
data["source"] = source
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps(data), encoding="utf-8")
return manifest_path, tmp_path / "bundle"
def test_adapter_stamp_equals_library_stamp(tmp_path: Path) -> None:
"""LOAD-BEARING ANTI-DRIFT: the adapter's ``load_manifest`` restates the §5 stamp formula
because the library (v0.3.2) mints the stamp inside ``materialize_bundle`` and exposes no
stamp helper. That is the ONE place the adapter is not purely delegating, so the two
formulas can drift apart silently — this compares the adapter's value against the stamp the
library actually writes into ``ingest_manifest`` frontmatter. RED the moment either side
changes how the stamp is computed."""
manifest_path, bundle_dir = _project(tmp_path)
_, adapter_stamp = load_manifest(manifest_path)
result = materialize_bundle(manifest_path, bundle_dir, _INGESTED_AT)
assert adapter_stamp == result.stamp
# ...and the stamp is what actually landed on disk, not merely an agreeing computation.
written_stamp = okf.parse_frontmatter(result.written[0])["ingest_manifest"]
assert adapter_stamp == written_stamp
# §5 shape, pinned independently of both implementations.
expected = "manifest@" + hashlib.sha256(manifest_path.read_bytes()).hexdigest()[:16]
assert adapter_stamp == expected
def test_adapter_returns_written_paths_in_extraction_order(tmp_path: Path) -> None:
"""The adapter keeps the repo's historical ``list[Path]`` return over the library's
``IngestResult``. RED if the unwrapping is dropped or reordered."""
manifest_path, bundle_dir = _project(tmp_path)
written = materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
assert isinstance(written, list)
assert [p.name for p in written] == ["ingest-costs.md"]
assert all(p.is_file() for p in written)
def test_local_only_default_holds_at_the_adapter_seam(tmp_path: Path) -> None:
"""LOAD-BEARING BOUNDARY (§8): the adapter's docstring claims an ``http`` source is refused
unless a run explicitly opts in — the manifest can never grant itself network. The library
owns the gate, but the adapter owns the DEFAULT it is called with. RED if the adapter ever
starts passing ``allow_network=True`` (or forwards a manifest-derived value)."""
manifest_path, bundle_dir = _project(
tmp_path, source={"type": "http", "id": "api", "base_url": "https://host/api"}
)
with pytest.raises(NetworkGateError) as exc:
materialize(manifest_path, bundle_dir, ingested_at=_INGESTED_AT)
assert exc.value.code == "network_opt_in_missing"
assert not bundle_dir.exists(), "a refused network source must write NOTHING"
# The opt-in is reachable, so the refusal above is a real default rather than a dead path.
calls: list[str] = []
def fake_get(url: str, credential: str | None) -> str:
calls.append(url)
return "ok\n"
materialize(
manifest_path,
bundle_dir,
ingested_at=_INGESTED_AT,
allow_network=True,
http_get=fake_get,
)
assert calls == ["https://host/api/costs.csv"]
def test_adapter_does_not_reimplement_door_a(tmp_path: Path) -> None:
"""The adoption's point: Door A improves in ONE place. This pins the adapter as thin — it
must not regrow a local connector/renderer/materializer. RED if the module starts carrying
the machinery it delegates (csv/sqlite/urllib reading, table escaping, frontmatter
rendering), which is how a 'temporary local fix' silently forks the shared implementation."""
source = (
Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / "ingest.py"
).read_text(encoding="utf-8")
for forbidden in ("import csv", "import sqlite3", "urlopen", "def render_table", "\\\\|"):
assert forbidden not in source, (
f"ingest.py reimplements Door A machinery ({forbidden!r}) — it must delegate to "
"llm-ingestion-okf, not fork it"
)