resource-control/tools/basis.py
tegwick 7a196b6265 feat: adopt canon 0.4.0-0.6.0 — EvidenceBasis is canon, uses_provisions is canon
Both demands were accepted. Adopting what landed.

EVIDENCE BASIS IS NOW ITC-GOV CANON (0.4.0)
tools/basis.py reads infospace/models/governance/evidence-basis.yaml instead of
defining its own vocabulary — same discipline we already applied to the
capability catalog. Two semantic changes came back that we did not have:

- estimated and assumed are peers in tier "judgement". We had them separately
  ranked, which asserted a difference the canon does not.
- derived belongs to no tier at all; asking for its tier before resolving it is
  now an error rather than a silent rank.

Tier membership is read from tiers[].members, not bases[].tier: the latter
labels invoiced/measured/quoted all as "evidenced" while the tier list splits
them across "observed" and "quoted". tiers[] is authoritative; reported upstream.

USES_PROVISIONS IS NOW CANON (0.5.0, CAP-R11)
Dropped the proposed_extensions marker. Renamed relation "uses" to "may_use" per
their migration note. tools/capability.py now enforces CAP-R11: relation must be
depends_on or may_use, a provider must be named, and a depends_on entry MUST be
declared between those capabilities in the catalog. data.backup gained catalog
may_use: security.secrets from our restatement, so our entry now checks out.

Also in 0.4.0: §10.3 changed so a joinable consumer record counts as promotion
proof, met by our restatement; ITC-CAP is now 0.4.0 / canon 0.6.0, status draft.
Record and tests updated to those versions.

196 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:57:46 +02:00

236 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Evidence basis: how a value was obtained, and how far it can be trusted.
The vocabulary is **owned by ITC-GOV** and read from
`infospace/models/governance/evidence-basis.yaml` in info-tech-canon. This
module does not vendor it. resource-control originated the concept, filed it as
demand, and now consumes the canon's version of it — drift in either repository
fails here rather than diverging quietly.
The central rule is that a derived value is only as strong as the weakest
*tier* among its inputs. Without it, arithmetic launders assumptions: EUR 30.00
of monthly labour reads like a measurement when it is another repository's
estimate of hours multiplied by a rate we chose.
"""
from __future__ import annotations
import json
import sys
from functools import lru_cache
from pathlib import Path
CATALOG_PATHS = (
Path("/home/worsch/info-tech-canon/infospace/models/governance/evidence-basis.yaml"),
Path("../info-tech-canon/infospace/models/governance/evidence-basis.yaml"),
)
@lru_cache(maxsize=1)
def load_catalog(paths: tuple = CATALOG_PATHS) -> dict:
"""Read the ITC-GOV EvidenceBasis catalog."""
import yaml
for path in paths:
if path.exists():
raw = yaml.safe_load(path.read_text())
tiers = {tier["id"]: tier for tier in raw["tiers"]}
bases = {b["id"]: b for b in raw["bases"]}
# Membership comes from tiers[].members, which is authoritative.
# The coarser bases[].tier label is not usable as a tier id: it
# reads "evidenced" for invoiced/measured/quoted while the tier
# list splits those across "observed" and "quoted".
tier_of = {
member: tier["id"]
for tier in raw["tiers"]
for member in tier["members"]
}
# A basis in no tier (derived) has no resolved tier by design.
tier_of.update({b: None for b in bases if b not in tier_of})
return {
"version": raw["canon"]["version"],
"canon_version": raw["canon"]["canon_version"],
"source": str(path),
"bases": bases,
"order": [b["id"] for b in raw["bases"]],
"tiers": tiers,
"tier_of": tier_of,
"tier_rank": {t["id"]: t["rank"] for t in raw["tiers"]},
"evidenced_tiers": {t["id"] for t in raw["tiers"] if t.get("evidenced")},
"grades": {g["id"]: g["when"] for g in raw["decision_grades"]},
}
raise FileNotFoundError(
"ITC-GOV EvidenceBasis catalog not found; is info-tech-canon checked out?"
)
def _catalog(catalog: dict | None = None) -> dict:
return catalog or load_catalog()
def bases(catalog: dict | None = None) -> list[str]:
return list(_catalog(catalog)["order"])
def tier_of(basis: str, catalog: dict | None = None) -> str:
"""The resolved strength tier of a basis.
`derived` deliberately has none: resolve it against its inputs first.
"""
cat = _catalog(catalog)
if basis not in cat["tier_of"]:
raise ValueError(f"unknown evidence basis {basis!r}")
tier = cat["tier_of"][basis]
if tier is None:
raise ValueError(
f"{basis!r} has no resolved tier; resolve it against derived_from first"
)
return tier
def rank(basis: str, catalog: dict | None = None) -> int:
"""Tier rank; lower is stronger. Members of one tier share a rank."""
cat = _catalog(catalog)
return cat["tier_rank"][tier_of(basis, cat)]
def weakest(names, catalog: dict | None = None) -> str:
"""The weakest basis in a collection, compared by tier.
Ties within a tier resolve by catalog order so the result is deterministic
without implying a strength difference the canon does not assert.
"""
cat = _catalog(catalog)
names = list(names)
if not names:
return "unknown"
order = cat["order"]
return min(names, key=lambda b: (-rank(b, cat), order.index(b)))
def strongest(names, catalog: dict | None = None) -> str:
cat = _catalog(catalog)
names = list(names)
if not names:
return "unknown"
order = cat["order"]
return min(names, key=lambda b: (rank(b, cat), order.index(b)))
def is_evidenced(basis: str, catalog: dict | None = None) -> bool:
"""True when the value asserts an observed or contracted fact.
`quoted` is weaker than `observed` for propagation and still counts as
evidenced for a decision grade — the canon separates those two uses.
"""
cat = _catalog(catalog)
return tier_of(basis, cat) in cat["evidenced_tiers"]
def resolve(value: dict, catalog: dict | None = None) -> str:
"""Effective basis of a value, propagating through derivation."""
cat = _catalog(catalog)
basis = value.get("basis", "unknown")
if basis not in cat["bases"]:
raise ValueError(f"unknown evidence basis {basis!r}")
if basis != "derived":
return basis
inputs = value.get("derived_from") or []
if not inputs:
raise ValueError("a derived value must record derived_from")
return weakest([resolve(item, cat) for item in inputs], cat)
def validate_value(value: dict, catalog: dict | None = None) -> None:
"""Enforce the ITC-GOV rules: unknown-is-not-zero and derived-names-inputs."""
cat = _catalog(catalog)
basis = value.get("basis")
if basis not in cat["bases"]:
raise ValueError(f"unknown evidence basis {basis!r}")
if basis == "derived" and not value.get("derived_from"):
raise ValueError("a derived value must record derived_from")
if basis != "derived" and value.get("derived_from"):
raise ValueError("only a derived value may record derived_from")
if basis == "unknown":
if value.get("value") is not None:
raise ValueError("a value with basis unknown must not carry a quantity")
if not value.get("gap"):
raise ValueError("an unknown value must name the gap and its owner")
elif value.get("value") is None:
raise ValueError("a known basis must carry a quantity; use basis unknown instead")
proxy_for = value.get("proxy_for")
if proxy_for is not None and not str(proxy_for).strip():
raise ValueError("proxy_for must name the quantity actually wanted")
for item in value.get("derived_from") or []:
validate_value(item, cat)
def profile(values, catalog: dict | None = None) -> dict:
"""Summarize a set of values for decision review."""
cat = _catalog(catalog)
resolved, proxies = [], []
for value in values:
validate_value(value, cat)
resolved.append(resolve(value, cat))
if value.get("proxy_for"):
proxies.append({"name": value.get("name"), "proxy_for": value["proxy_for"]})
counts: dict[str, int] = {}
for basis in resolved:
counts[basis] = counts.get(basis, 0) + 1
evidenced = [b for b in resolved if is_evidenced(b, cat)]
weakest_basis = weakest(resolved, cat)
return {
"count": len(resolved),
"by_basis": {k: counts[k] for k in cat["order"] if k in counts},
"weakest": weakest_basis,
"weakest_tier": tier_of(weakest_basis, cat) if resolved else "unknown",
"evidenced": len(evidenced),
"evidenced_ratio": round(len(evidenced) / len(resolved), 4) if resolved else None,
"proxies": proxies,
}
# Decision grade per tier, from the catalog's decision_grades.
_GRADE_BY_TIER = {
"observed": "evidenced",
"quoted": "evidenced",
"projected": "projected",
"judgement": "indicative",
"unknown": "insufficient",
}
_GRADE_NOTE = {
"evidenced": "every load-bearing value is observed, invoiced, or quoted",
"projected": "the conclusion rests on values projected from observations",
"indicative": "the conclusion is no stronger than an estimated or assumed value",
"insufficient": "at least one load-bearing value is unknown",
}
def decision_grade(values, catalog: dict | None = None) -> dict:
"""Grade a decision by the weakest tier it actually rests on.
A conclusion is not stronger than its weakest load-bearing input, however
precise the arithmetic between them looks.
"""
cat = _catalog(catalog)
summary = profile(values, cat)
tier = summary["weakest_tier"]
grade = _GRADE_BY_TIER[tier]
note = _GRADE_NOTE[grade]
if summary["proxies"]:
note += f"; {len(summary['proxies'])} value(s) measure a proxy rather than the quantity named"
return {**summary, "grade": grade, "note": note}
def main() -> int:
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} VALUES.json", file=sys.stderr)
return 2
payload = json.loads(Path(sys.argv[1]).read_text())
values = payload["values"] if isinstance(payload, dict) else payload
print(json.dumps(decision_grade(values), indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())