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>
This commit is contained in:
parent
b8081f6c2d
commit
7a196b6265
6 changed files with 292 additions and 131 deletions
|
|
@ -3,9 +3,11 @@
|
||||||
"record_scope": "operational",
|
"record_scope": "operational",
|
||||||
"canon": {
|
"canon": {
|
||||||
"model": "ITC-CAP",
|
"model": "ITC-CAP",
|
||||||
"model_version": "0.2.0",
|
"model_version": "0.4.0",
|
||||||
"canon_version": "0.3.0",
|
"canon_version": "0.6.0",
|
||||||
"catalog": "info-tech-canon/infospace/models/capability/capabilities.yaml"
|
"status": "draft",
|
||||||
|
"catalog": "info-tech-canon/infospace/models/capability/capabilities.yaml",
|
||||||
|
"evidence_basis_catalog": "info-tech-canon/infospace/models/governance/evidence-basis.yaml"
|
||||||
},
|
},
|
||||||
"record_id": "capability-case:platform-audit-storage:2026-08",
|
"record_id": "capability-case:platform-audit-storage:2026-08",
|
||||||
"created_at": "2026-08-15T00:00:00Z",
|
"created_at": "2026-08-15T00:00:00Z",
|
||||||
|
|
@ -197,7 +199,7 @@
|
||||||
{
|
{
|
||||||
"capability": "security.secrets",
|
"capability": "security.secrets",
|
||||||
"provider": "OpenBao / external-secrets on reef-railiance",
|
"provider": "OpenBao / external-secrets on reef-railiance",
|
||||||
"relation": "uses",
|
"relation": "may_use",
|
||||||
"note": "ClusterSecretStore openbao-backup-object-storage; ExternalSecret synced to databases/platform-pg-backup-s3. Not a consumption row: no purchased platform capacity is bought here, another capability is used.",
|
"note": "ClusterSecretStore openbao-backup-object-storage; ExternalSecret synced to databases/platform-pg-backup-s3. Not a consumption row: no purchased platform capacity is bought here, another capability is used.",
|
||||||
"evidence_basis": "measured",
|
"evidence_basis": "measured",
|
||||||
"observed_at": "2026-08-14"
|
"observed_at": "2026-08-14"
|
||||||
|
|
@ -278,14 +280,6 @@
|
||||||
"The requirement asks for data.backup at D5; the provision is D4. Closing it needs a drill cadence, an emitted wal_archive_gap_minutes, and more than one backup.",
|
"The requirement asks for data.backup at D5; the provision is D4. Closing it needs a drill cadence, an emitted wal_archive_gap_minutes, and more than one backup.",
|
||||||
"Class I consumption is unknown on both provisions. resource-control does not meter tokens against a provision yet.",
|
"Class I consumption is unknown on both provisions. resource-control does not meter tokens against a provision yet.",
|
||||||
"Class H on the data.backup provision is unknown: effort was spent and not recorded. A time record starts in 2026-09.",
|
"Class H on the data.backup provision is unknown: effort was spent and not recorded. A time record starts in 2026-09.",
|
||||||
"No invoiced basis exists anywhere in this record. The first booked Scaleway cost from fin-hub (FIN-WP-0004) would be the first.",
|
"No invoiced basis exists anywhere in this record. The first booked Scaleway cost from fin-hub (FIN-WP-0004) would be the first."
|
||||||
"provisions[].uses_provisions is a proposed extension, not canon. It replaced a consumes:P row for credential custody, which was the wrong kind: P is purchased platform capacity, and using security.secrets buys none."
|
]
|
||||||
],
|
|
||||||
"proposed_extensions": {
|
|
||||||
"note": "uses_provisions is NOT canon. ITC-CAP 0.2.0 declares capability-to-capability relations and landscape-to-capability relations, but no provision-to-provision relation. info-tech-canon identified the gap on 2026-08-15 and asked us to file it as demand rather than have them invent the field from a message.",
|
|
||||||
"fields": [
|
|
||||||
"provisions[].uses_provisions"
|
|
||||||
],
|
|
||||||
"demand": "info-tech-canon/demand/ProvisionRelationships.md"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,40 @@ A counted object and an assumed hourly rate are both numbers. They are not both
|
||||||
knowledge. Every quantity in this repository declares **how it was obtained**,
|
knowledge. Every quantity in this repository declares **how it was obtained**,
|
||||||
so a decision can be graded by the weakest thing it actually rests on.
|
so a decision can be graded by the weakest thing it actually rests on.
|
||||||
|
|
||||||
Vocabulary and propagation: `tools/basis.py`.
|
**The vocabulary is canon, owned by ITC-GOV.** resource-control originated the
|
||||||
|
concept, filed it as `demand/EvidenceBasis.md`, and info-tech-canon adopted it in
|
||||||
|
canon 0.4.0 as `infospace/models/governance/evidence-basis.yaml`. `tools/basis.py`
|
||||||
|
now *reads* that catalog rather than defining its own — the same discipline we
|
||||||
|
apply to the capability catalog. Drift in either repository fails our suite.
|
||||||
|
`CAP-R10` requires a basis on every `CapabilityConsumption` row.
|
||||||
|
|
||||||
## The scale
|
## The scale
|
||||||
|
|
||||||
Ordered strongest to weakest. The order is the point — it is what makes
|
Strength is a **tier**, not a total order. Members of one tier are peers and do
|
||||||
"weakest input wins" computable.
|
not rank against each other — asserting an order between them would make the
|
||||||
|
propagation rule claim something it cannot know.
|
||||||
|
|
||||||
| Basis | Meaning | Example here |
|
| Tier | Rank | Bases | Example here |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `invoiced` | A booked financial fact, authoritative from fin-hub | none yet |
|
| `observed` | 0 | `invoiced`, `measured` | 8 objects, 6 604 031 B in the backup prefix |
|
||||||
| `measured` | Directly observed from the authoritative system | 8 objects, 6 604 031 B in the backup prefix |
|
| `quoted` | 1 | `quoted` | Scaleway €0.01606/GB-month |
|
||||||
| `quoted` | Stated by a provider or counterparty in a citable source | Scaleway €0.01606/GB-month |
|
| `projected` | 2 | `projected` | 457.968 GB stored at month 12 |
|
||||||
| `derived` | Computed from other values by a stated rule | €7.35/month infrastructure |
|
| `judgement` | 3 | `estimated`, `assumed` | apps-pg 6 operator-hours setup; €60/hour rate |
|
||||||
| `projected` | Interpolated between, or extrapolated beyond, observations | 457.968 GB stored at month 12 |
|
| `unknown` | 4 | `unknown` | railiance01 monthly price |
|
||||||
| `estimated` | Human judgement, neither observed nor computed | apps-pg 6 operator-hours setup |
|
|
||||||
| `assumed` | A modelling constant we chose | €60/hour operator rate |
|
|
||||||
| `unknown` | No value exists | railiance01 monthly price |
|
|
||||||
|
|
||||||
`invoiced`, `measured`, and `quoted` are **evidenced**: they assert an observed
|
`derived` belongs to **no tier**: it resolves against its inputs, and asking for
|
||||||
or contracted fact. Everything below them is inference.
|
its tier before resolution is an error.
|
||||||
|
|
||||||
|
`observed` and `quoted` are both **evidenced** — they assert an observed or
|
||||||
|
contracted fact. `quoted` sits below `observed` for propagation while still
|
||||||
|
counting as evidenced for a decision grade; the canon separates those two uses
|
||||||
|
deliberately, because a counterparty's stated price is a fact about a claim and
|
||||||
|
a measurement is a fact about the world.
|
||||||
|
|
||||||
|
An invoice is authoritative for a payment and a measurement is authoritative for
|
||||||
|
a quantity, so neither outranks the other — they are peers in `observed`. Our
|
||||||
|
first implementation used a strict list order and got this wrong; the canon
|
||||||
|
carries the corrected form as a normative rule.
|
||||||
|
|
||||||
## The propagation rule
|
## The propagation rule
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parents[1] / "tools"))
|
sys.path.insert(0, str(Path(__file__).parents[1] / "tools"))
|
||||||
from basis import (
|
from basis import (
|
||||||
BASIS_ORDER,
|
bases as catalog_bases,
|
||||||
|
load_catalog,
|
||||||
|
tier_of,
|
||||||
decision_grade,
|
decision_grade,
|
||||||
is_evidenced,
|
is_evidenced,
|
||||||
profile,
|
profile,
|
||||||
|
|
@ -24,22 +26,54 @@ def value(basis="measured", **overrides):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class OrderTest(unittest.TestCase):
|
class CanonBindingTest(unittest.TestCase):
|
||||||
|
"""The vocabulary is owned by ITC-GOV; we read it, we do not vendor it."""
|
||||||
|
|
||||||
|
def test_catalog_is_read_from_the_canon(self):
|
||||||
|
catalog = load_catalog()
|
||||||
|
self.assertEqual("0.1.0", catalog["version"])
|
||||||
|
self.assertIn("info-tech-canon", catalog["source"])
|
||||||
|
|
||||||
|
def test_every_basis_comes_from_the_canon_catalog(self):
|
||||||
|
self.assertEqual(
|
||||||
|
["invoiced", "measured", "quoted", "derived", "projected", "estimated",
|
||||||
|
"assumed", "unknown"],
|
||||||
|
catalog_bases(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TierTest(unittest.TestCase):
|
||||||
def test_order_runs_strongest_to_weakest(self):
|
def test_order_runs_strongest_to_weakest(self):
|
||||||
self.assertEqual("invoiced", BASIS_ORDER[0])
|
self.assertLess(rank("measured"), rank("projected"))
|
||||||
self.assertEqual("unknown", BASIS_ORDER[-1])
|
self.assertLess(rank("projected"), rank("estimated"))
|
||||||
self.assertLess(rank("measured"), rank("estimated"))
|
self.assertLess(rank("estimated"), rank("unknown"))
|
||||||
self.assertLess(rank("estimated"), rank("assumed"))
|
|
||||||
|
|
||||||
def test_invoiced_and_measured_are_peers_not_ranked(self):
|
def test_invoiced_and_measured_are_peers_not_ranked(self):
|
||||||
"""An invoice is authoritative for a payment, a measurement for a
|
"""An invoice is authoritative for a payment, a measurement for a
|
||||||
quantity. Neither outranks the other outside its own domain."""
|
quantity. Neither outranks the other outside its own domain."""
|
||||||
|
self.assertEqual("observed", tier_of("invoiced"))
|
||||||
|
self.assertEqual("observed", tier_of("measured"))
|
||||||
self.assertEqual(rank("invoiced"), rank("measured"))
|
self.assertEqual(rank("invoiced"), rank("measured"))
|
||||||
self.assertLess(rank("measured"), rank("quoted"))
|
self.assertLess(rank("measured"), rank("quoted"))
|
||||||
|
|
||||||
|
def test_estimated_and_assumed_are_peers_in_the_judgement_tier(self):
|
||||||
|
"""Canon 0.4.0 groups them; neither is stronger than the other."""
|
||||||
|
self.assertEqual("judgement", tier_of("estimated"))
|
||||||
|
self.assertEqual("judgement", tier_of("assumed"))
|
||||||
|
self.assertEqual(rank("estimated"), rank("assumed"))
|
||||||
|
|
||||||
|
def test_quoted_is_below_observed_for_propagation_but_still_evidenced(self):
|
||||||
|
self.assertGreater(rank("quoted"), rank("measured"))
|
||||||
|
self.assertTrue(is_evidenced("quoted"))
|
||||||
|
|
||||||
|
def test_derived_has_no_resolved_tier(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "no resolved tier"):
|
||||||
|
tier_of("derived")
|
||||||
|
|
||||||
def test_a_peer_pair_does_not_report_a_false_weakest(self):
|
def test_a_peer_pair_does_not_report_a_false_weakest(self):
|
||||||
self.assertEqual(rank("invoiced"), rank(weakest(["invoiced", "measured"])))
|
self.assertEqual(rank("invoiced"), rank(weakest(["invoiced", "measured"])))
|
||||||
self.assertEqual(weakest(["invoiced", "measured"]), weakest(["measured", "invoiced"]))
|
self.assertEqual(weakest(["invoiced", "measured"]), weakest(["measured", "invoiced"]))
|
||||||
|
self.assertEqual(weakest(["estimated", "assumed"]), weakest(["assumed", "estimated"]))
|
||||||
|
|
||||||
def test_weakest_and_strongest_pick_opposite_ends(self):
|
def test_weakest_and_strongest_pick_opposite_ends(self):
|
||||||
bases = ["measured", "assumed", "quoted"]
|
bases = ["measured", "assumed", "quoted"]
|
||||||
|
|
@ -52,8 +86,11 @@ class OrderTest(unittest.TestCase):
|
||||||
def test_only_observed_or_contracted_bases_count_as_evidenced(self):
|
def test_only_observed_or_contracted_bases_count_as_evidenced(self):
|
||||||
for basis in ("invoiced", "measured", "quoted"):
|
for basis in ("invoiced", "measured", "quoted"):
|
||||||
self.assertTrue(is_evidenced(basis))
|
self.assertTrue(is_evidenced(basis))
|
||||||
for basis in ("derived", "projected", "estimated", "assumed", "unknown"):
|
for basis in ("projected", "estimated", "assumed", "unknown"):
|
||||||
self.assertFalse(is_evidenced(basis))
|
self.assertFalse(is_evidenced(basis))
|
||||||
|
# derived is not evidenced or unevidenced until it is resolved
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
is_evidenced("derived")
|
||||||
|
|
||||||
def test_unknown_basis_name_is_rejected(self):
|
def test_unknown_basis_name_is_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
|
|
@ -69,12 +106,12 @@ class PropagationTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_one_assumed_input_drags_the_result_down(self):
|
def test_one_assumed_input_drags_the_result_down(self):
|
||||||
derived = value("derived", derived_from=[value("quoted"), value("assumed")])
|
derived = value("derived", derived_from=[value("quoted"), value("assumed")])
|
||||||
self.assertEqual("assumed", resolve(derived))
|
self.assertEqual("judgement", tier_of(resolve(derived)))
|
||||||
|
|
||||||
def test_propagation_is_recursive(self):
|
def test_propagation_is_recursive(self):
|
||||||
inner = value("derived", derived_from=[value("measured"), value("estimated")])
|
inner = value("derived", derived_from=[value("measured"), value("estimated")])
|
||||||
outer = value("derived", derived_from=[value("measured"), inner])
|
outer = value("derived", derived_from=[value("measured"), inner])
|
||||||
self.assertEqual("estimated", resolve(outer))
|
self.assertEqual("judgement", tier_of(resolve(outer)))
|
||||||
|
|
||||||
def test_derived_without_inputs_is_rejected(self):
|
def test_derived_without_inputs_is_rejected(self):
|
||||||
with self.assertRaisesRegex(ValueError, "derived_from"):
|
with self.assertRaisesRegex(ValueError, "derived_from"):
|
||||||
|
|
@ -120,7 +157,7 @@ class DecisionGradeTest(unittest.TestCase):
|
||||||
def test_a_single_assumption_makes_the_whole_decision_indicative(self):
|
def test_a_single_assumption_makes_the_whole_decision_indicative(self):
|
||||||
result = decision_grade([value("measured"), value("measured"), value("assumed")])
|
result = decision_grade([value("measured"), value("measured"), value("assumed")])
|
||||||
self.assertEqual("indicative", result["grade"])
|
self.assertEqual("indicative", result["grade"])
|
||||||
self.assertEqual("assumed", result["weakest"])
|
self.assertEqual("judgement", result["weakest_tier"])
|
||||||
|
|
||||||
def test_an_unknown_makes_the_decision_insufficient(self):
|
def test_an_unknown_makes_the_decision_insufficient(self):
|
||||||
result = decision_grade([value("measured"), value("unknown")])
|
result = decision_grade([value("measured"), value("unknown")])
|
||||||
|
|
@ -135,7 +172,7 @@ class DecisionGradeTest(unittest.TestCase):
|
||||||
derived_from=[value("assumed", name="hours"), value("assumed", name="rate")],
|
derived_from=[value("assumed", name="hours"), value("assumed", name="rate")],
|
||||||
)
|
)
|
||||||
result = decision_grade([value("quoted", name="price"), euros])
|
result = decision_grade([value("quoted", name="price"), euros])
|
||||||
self.assertEqual("assumed", result["weakest"])
|
self.assertEqual("judgement", result["weakest_tier"])
|
||||||
self.assertEqual("indicative", result["grade"])
|
self.assertEqual("indicative", result["grade"])
|
||||||
|
|
||||||
def test_projected_values_grade_separately_from_estimates(self):
|
def test_projected_values_grade_separately_from_estimates(self):
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,10 @@ class CanonBindingTest(unittest.TestCase):
|
||||||
self.canon = canon()
|
self.canon = canon()
|
||||||
|
|
||||||
def test_catalog_is_the_version_we_restated_against(self):
|
def test_catalog_is_the_version_we_restated_against(self):
|
||||||
self.assertEqual("0.2.0", self.canon["version"])
|
self.assertEqual("0.4.0", self.canon["version"])
|
||||||
self.assertEqual("0.3.0", self.canon["canon_version"])
|
self.assertEqual("0.6.0", self.canon["canon_version"])
|
||||||
|
self.assertEqual(self.canon["version"], RECORD["canon"]["model_version"])
|
||||||
|
self.assertEqual(self.canon["canon_version"], RECORD["canon"]["canon_version"])
|
||||||
|
|
||||||
def test_human_effort_and_intelligence_classes_exist_with_native_units(self):
|
def test_human_effort_and_intelligence_classes_exist_with_native_units(self):
|
||||||
classes = self.canon["resource_classes"]
|
classes = self.canon["resource_classes"]
|
||||||
|
|
@ -133,6 +135,34 @@ class ProvisionTest(unittest.TestCase):
|
||||||
self.assertFalse(coverage["complete"])
|
self.assertFalse(coverage["complete"])
|
||||||
self.assertIn("object_integrity_tests", coverage["missing"])
|
self.assertIn("object_integrity_tests", coverage["missing"])
|
||||||
|
|
||||||
|
def test_using_another_capability_is_a_relation_not_a_p_row(self):
|
||||||
|
"""CAP-R11. The credential-custody P row was the wrong kind."""
|
||||||
|
backup = self.provisions["data.backup"]
|
||||||
|
classes = {r["class"] for r in backup["consumes"]}
|
||||||
|
self.assertNotIn("P", classes)
|
||||||
|
relations = {u["capability"]: u["relation"] for u in backup["uses_provisions"]}
|
||||||
|
self.assertEqual("may_use", relations["security.secrets"])
|
||||||
|
self.assertEqual("depends_on", relations["data.object"])
|
||||||
|
|
||||||
|
def test_depends_on_must_be_declared_between_the_capabilities(self):
|
||||||
|
backup = self.provisions["data.backup"]
|
||||||
|
entry = next(u for u in backup["uses_provisions"] if u["capability"] == "security.secrets")
|
||||||
|
entry["relation"] = "depends_on"
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not declare depends_on"):
|
||||||
|
validate_provision(backup, self.canon)
|
||||||
|
|
||||||
|
def test_relation_outside_the_canon_vocabulary_is_rejected(self):
|
||||||
|
backup = self.provisions["data.backup"]
|
||||||
|
backup["uses_provisions"][0]["relation"] = "uses"
|
||||||
|
with self.assertRaisesRegex(ValueError, "depends_on or may_use"):
|
||||||
|
validate_provision(backup, self.canon)
|
||||||
|
|
||||||
|
def test_a_used_provision_must_name_its_provider(self):
|
||||||
|
backup = self.provisions["data.backup"]
|
||||||
|
backup["uses_provisions"][0]["provider"] = ""
|
||||||
|
with self.assertRaisesRegex(ValueError, "must name a provider"):
|
||||||
|
validate_provision(backup, self.canon)
|
||||||
|
|
||||||
def test_effort_and_tokens_are_recorded_in_native_units(self):
|
def test_effort_and_tokens_are_recorded_in_native_units(self):
|
||||||
rows = {r["class"]: r for r in self.provisions["data.object"]["consumes"]}
|
rows = {r["class"]: r for r in self.provisions["data.object"]["consumes"]}
|
||||||
self.assertEqual("hour", rows["H"]["quantity"]["unit"])
|
self.assertEqual("hour", rows["H"]["quantity"]["unit"])
|
||||||
|
|
|
||||||
231
tools/basis.py
231
tools/basis.py
|
|
@ -1,13 +1,15 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Evidence basis: how a value was obtained, and how far it can be trusted.
|
"""Evidence basis: how a value was obtained, and how far it can be trusted.
|
||||||
|
|
||||||
Every quantity in this repository is one of a small number of epistemic kinds.
|
The vocabulary is **owned by ITC-GOV** and read from
|
||||||
A counted object and an assumed hourly rate are both numbers; they are not both
|
`infospace/models/governance/evidence-basis.yaml` in info-tech-canon. This
|
||||||
knowledge. This module names the difference and propagates it.
|
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 its weakest
|
The central rule is that a derived value is only as strong as the weakest
|
||||||
input. Without it, a precise-looking figure launders weak assumptions: EUR 30.00
|
*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
|
of monthly labour reads like a measurement when it is another repository's
|
||||||
estimate of hours multiplied by a rate we chose.
|
estimate of hours multiplied by a rate we chose.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -15,94 +17,135 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ordered strongest to weakest. The order is the whole point: it is what makes
|
CATALOG_PATHS = (
|
||||||
# "weakest input wins" computable.
|
Path("/home/worsch/info-tech-canon/infospace/models/governance/evidence-basis.yaml"),
|
||||||
BASIS_ORDER = (
|
Path("../info-tech-canon/infospace/models/governance/evidence-basis.yaml"),
|
||||||
"invoiced", # a booked financial fact, authoritative from fin-hub
|
|
||||||
"measured", # directly observed from the authoritative system
|
|
||||||
"quoted", # stated by a provider or counterparty in a citable source
|
|
||||||
"derived", # computed from other values by a stated rule
|
|
||||||
"projected", # interpolated between, or extrapolated beyond, observations
|
|
||||||
"estimated", # human judgement, neither observed nor computed
|
|
||||||
"assumed", # a modelling constant we chose
|
|
||||||
"unknown", # no value exists
|
|
||||||
)
|
)
|
||||||
BASES = frozenset(BASIS_ORDER)
|
|
||||||
|
|
||||||
# Strength is a tier, not a total order. `invoiced` and `measured` are peers:
|
|
||||||
# an invoice is the authoritative record of a payment, a measurement is the
|
|
||||||
# authoritative record of a quantity, and neither outranks the other outside
|
|
||||||
# its own domain. Asserting an order between them would make the weakest-input
|
|
||||||
# rule claim something it cannot know.
|
|
||||||
_TIER = {
|
|
||||||
"invoiced": 0, "measured": 0,
|
|
||||||
"quoted": 1,
|
|
||||||
"derived": 2,
|
|
||||||
"projected": 3,
|
|
||||||
"estimated": 4,
|
|
||||||
"assumed": 5,
|
|
||||||
"unknown": 6,
|
|
||||||
}
|
|
||||||
_ORDER = {name: index for index, name in enumerate(BASIS_ORDER)}
|
|
||||||
|
|
||||||
# Bases that assert an observed or contracted fact about the world.
|
|
||||||
EVIDENCED = frozenset({"invoiced", "measured", "quoted"})
|
|
||||||
|
|
||||||
|
|
||||||
def rank(basis: str) -> int:
|
@lru_cache(maxsize=1)
|
||||||
"""Strength tier; lower is stronger. Peers share a tier."""
|
def load_catalog(paths: tuple = CATALOG_PATHS) -> dict:
|
||||||
if basis not in _TIER:
|
"""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}")
|
raise ValueError(f"unknown evidence basis {basis!r}")
|
||||||
return _TIER[basis]
|
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 weakest(bases) -> str:
|
def rank(basis: str, catalog: dict | None = None) -> int:
|
||||||
"""The weakest basis in a collection. Empty means nothing is known.
|
"""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
|
Ties within a tier resolve by catalog order so the result is deterministic
|
||||||
without implying a strength difference that does not exist.
|
without implying a strength difference the canon does not assert.
|
||||||
"""
|
"""
|
||||||
bases = list(bases)
|
cat = _catalog(catalog)
|
||||||
if not bases:
|
names = list(names)
|
||||||
|
if not names:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
return min(bases, key=lambda b: (-rank(b), _ORDER[b]))
|
order = cat["order"]
|
||||||
|
return min(names, key=lambda b: (-rank(b, cat), order.index(b)))
|
||||||
|
|
||||||
|
|
||||||
def strongest(bases) -> str:
|
def strongest(names, catalog: dict | None = None) -> str:
|
||||||
bases = list(bases)
|
cat = _catalog(catalog)
|
||||||
if not bases:
|
names = list(names)
|
||||||
|
if not names:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
return min(bases, key=lambda b: (rank(b), _ORDER[b]))
|
order = cat["order"]
|
||||||
|
return min(names, key=lambda b: (rank(b, cat), order.index(b)))
|
||||||
|
|
||||||
|
|
||||||
def is_evidenced(basis: str) -> bool:
|
def is_evidenced(basis: str, catalog: dict | None = None) -> bool:
|
||||||
"""True when the value asserts an observed or contracted fact."""
|
"""True when the value asserts an observed or contracted fact.
|
||||||
return basis in EVIDENCED
|
|
||||||
|
|
||||||
|
`quoted` is weaker than `observed` for propagation and still counts as
|
||||||
def resolve(value: dict) -> str:
|
evidenced for a decision grade — the canon separates those two uses.
|
||||||
"""Effective basis of a value, propagating through derivation.
|
|
||||||
|
|
||||||
A `derived` value resolves to the weakest basis among its inputs: deriving
|
|
||||||
GB from measured bytes stays measured, while deriving euros from estimated
|
|
||||||
hours and an assumed rate is no better than assumed.
|
|
||||||
"""
|
"""
|
||||||
|
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")
|
basis = value.get("basis", "unknown")
|
||||||
if basis not in BASES:
|
if basis not in cat["bases"]:
|
||||||
raise ValueError(f"unknown evidence basis {basis!r}")
|
raise ValueError(f"unknown evidence basis {basis!r}")
|
||||||
if basis != "derived":
|
if basis != "derived":
|
||||||
return basis
|
return basis
|
||||||
inputs = value.get("derived_from") or []
|
inputs = value.get("derived_from") or []
|
||||||
if not inputs:
|
if not inputs:
|
||||||
raise ValueError("a derived value must record derived_from")
|
raise ValueError("a derived value must record derived_from")
|
||||||
return weakest(resolve(item) for item in inputs)
|
return weakest([resolve(item, cat) for item in inputs], cat)
|
||||||
|
|
||||||
|
|
||||||
def validate_value(value: dict) -> None:
|
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")
|
basis = value.get("basis")
|
||||||
if basis not in BASES:
|
if basis not in cat["bases"]:
|
||||||
raise ValueError(f"unknown evidence basis {basis!r}")
|
raise ValueError(f"unknown evidence basis {basis!r}")
|
||||||
if basis == "derived" and not value.get("derived_from"):
|
if basis == "derived" and not value.get("derived_from"):
|
||||||
raise ValueError("a derived value must record derived_from")
|
raise ValueError("a derived value must record derived_from")
|
||||||
|
|
@ -115,55 +158,65 @@ def validate_value(value: dict) -> None:
|
||||||
raise ValueError("an unknown value must name the gap and its owner")
|
raise ValueError("an unknown value must name the gap and its owner")
|
||||||
elif value.get("value") is None:
|
elif value.get("value") is None:
|
||||||
raise ValueError("a known basis must carry a quantity; use basis unknown instead")
|
raise ValueError("a known basis must carry a quantity; use basis unknown instead")
|
||||||
# A proxy measures a different quantity than the one named. That does not
|
|
||||||
# weaken the measurement, but it does weaken the inference drawn from it.
|
|
||||||
proxy_for = value.get("proxy_for")
|
proxy_for = value.get("proxy_for")
|
||||||
if proxy_for is not None and not str(proxy_for).strip():
|
if proxy_for is not None and not str(proxy_for).strip():
|
||||||
raise ValueError("proxy_for must name the quantity actually wanted")
|
raise ValueError("proxy_for must name the quantity actually wanted")
|
||||||
for item in value.get("derived_from") or []:
|
for item in value.get("derived_from") or []:
|
||||||
validate_value(item)
|
validate_value(item, cat)
|
||||||
|
|
||||||
|
|
||||||
def profile(values) -> dict:
|
def profile(values, catalog: dict | None = None) -> dict:
|
||||||
"""Summarize a set of values for decision review."""
|
"""Summarize a set of values for decision review."""
|
||||||
resolved = []
|
cat = _catalog(catalog)
|
||||||
proxies = []
|
resolved, proxies = [], []
|
||||||
for value in values:
|
for value in values:
|
||||||
validate_value(value)
|
validate_value(value, cat)
|
||||||
effective = resolve(value)
|
resolved.append(resolve(value, cat))
|
||||||
resolved.append(effective)
|
|
||||||
if value.get("proxy_for"):
|
if value.get("proxy_for"):
|
||||||
proxies.append({"name": value.get("name"), "proxy_for": value["proxy_for"]})
|
proxies.append({"name": value.get("name"), "proxy_for": value["proxy_for"]})
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for basis in resolved:
|
for basis in resolved:
|
||||||
counts[basis] = counts.get(basis, 0) + 1
|
counts[basis] = counts.get(basis, 0) + 1
|
||||||
evidenced = [b for b in resolved if is_evidenced(b)]
|
evidenced = [b for b in resolved if is_evidenced(b, cat)]
|
||||||
|
weakest_basis = weakest(resolved, cat)
|
||||||
return {
|
return {
|
||||||
"count": len(resolved),
|
"count": len(resolved),
|
||||||
"by_basis": {k: counts[k] for k in BASIS_ORDER if k in counts},
|
"by_basis": {k: counts[k] for k in cat["order"] if k in counts},
|
||||||
"weakest": weakest(resolved),
|
"weakest": weakest_basis,
|
||||||
|
"weakest_tier": tier_of(weakest_basis, cat) if resolved else "unknown",
|
||||||
"evidenced": len(evidenced),
|
"evidenced": len(evidenced),
|
||||||
"evidenced_ratio": round(len(evidenced) / len(resolved), 4) if resolved else None,
|
"evidenced_ratio": round(len(evidenced) / len(resolved), 4) if resolved else None,
|
||||||
"proxies": proxies,
|
"proxies": proxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def decision_grade(values) -> dict:
|
# Decision grade per tier, from the catalog's decision_grades.
|
||||||
"""Grade a decision by the weakest evidence it actually rests on.
|
_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
|
A conclusion is not stronger than its weakest load-bearing input, however
|
||||||
precise the arithmetic between them looks.
|
precise the arithmetic between them looks.
|
||||||
"""
|
"""
|
||||||
summary = profile(values)
|
cat = _catalog(catalog)
|
||||||
basis = summary["weakest"]
|
summary = profile(values, cat)
|
||||||
if basis == "unknown":
|
tier = summary["weakest_tier"]
|
||||||
grade, note = "insufficient", "at least one load-bearing value is unknown"
|
grade = _GRADE_BY_TIER[tier]
|
||||||
elif is_evidenced(basis):
|
note = _GRADE_NOTE[grade]
|
||||||
grade, note = "evidenced", "every load-bearing value is observed, invoiced, or quoted"
|
|
||||||
elif basis == "projected":
|
|
||||||
grade, note = "projected", "the conclusion rests on values projected from observations"
|
|
||||||
else:
|
|
||||||
grade, note = "indicative", f"the conclusion is no stronger than an {basis} value" if basis[0] in "aeiou" else f"the conclusion is no stronger than a {basis} value"
|
|
||||||
if summary["proxies"]:
|
if summary["proxies"]:
|
||||||
note += f"; {len(summary['proxies'])} value(s) measure a proxy rather than the quantity named"
|
note += f"; {len(summary['proxies'])} value(s) measure a proxy rather than the quantity named"
|
||||||
return {**summary, "grade": grade, "note": note}
|
return {**summary, "grade": grade, "note": note}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,35 @@ def validate_provision(provision: dict, canon: dict) -> None:
|
||||||
if row.get("supply") and row["supply"] not in {"internal", "external"}:
|
if row.get("supply") and row["supply"] not in {"internal", "external"}:
|
||||||
raise ValueError(f"supply must be internal or external, got {row['supply']!r}")
|
raise ValueError(f"supply must be internal or external, got {row['supply']!r}")
|
||||||
|
|
||||||
|
validate_uses_provisions(provision, canon)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uses_provisions(provision: dict, canon: dict) -> None:
|
||||||
|
"""CAP-R11: relying on another provision is a relationship, not consumption.
|
||||||
|
|
||||||
|
A `depends_on` entry MUST correspond to a `depends_on` declared between the
|
||||||
|
two capabilities in the catalog, so the provision graph stays checkable
|
||||||
|
against the capability graph rather than free-form.
|
||||||
|
"""
|
||||||
|
capability = canon["capabilities"][provision["capability"]]
|
||||||
|
for entry in provision.get("uses_provisions") or []:
|
||||||
|
target = entry["capability"]
|
||||||
|
if target not in canon["capabilities"]:
|
||||||
|
raise ValueError(f"unknown capability {target} in uses_provisions")
|
||||||
|
relation = entry["relation"]
|
||||||
|
if relation not in {"depends_on", "may_use"}:
|
||||||
|
raise ValueError(f"relation must be depends_on or may_use, got {relation!r}")
|
||||||
|
if not entry.get("provider"):
|
||||||
|
raise ValueError(f"uses_provisions entry for {target} must name a provider")
|
||||||
|
declared = set(capability.get(relation) or [])
|
||||||
|
if relation == "depends_on" and target not in declared:
|
||||||
|
raise ValueError(
|
||||||
|
f"{provision['capability']} does not declare depends_on {target} in the catalog"
|
||||||
|
)
|
||||||
|
if relation == "may_use" and target not in declared:
|
||||||
|
# SHOULD, not MUST — surfaced rather than fatal.
|
||||||
|
entry.setdefault("_note", f"catalog does not declare may_use {target}")
|
||||||
|
|
||||||
|
|
||||||
def evidence_coverage(provision: dict, canon: dict) -> dict:
|
def evidence_coverage(provision: dict, canon: dict) -> dict:
|
||||||
"""Which of the capability's declared evidence hooks this provision satisfies."""
|
"""Which of the capability's declared evidence hooks this provision satisfies."""
|
||||||
|
|
@ -185,6 +214,10 @@ def review(record: dict, canon: dict) -> dict:
|
||||||
"maturity": provision["maturity"],
|
"maturity": provision["maturity"],
|
||||||
"evidence": evidence_coverage(provision, canon),
|
"evidence": evidence_coverage(provision, canon),
|
||||||
"consumption": consumption_profile(provision),
|
"consumption": consumption_profile(provision),
|
||||||
|
"uses_provisions": [
|
||||||
|
{"capability": u["capability"], "relation": u["relation"], "provider": u["provider"]}
|
||||||
|
for u in provision.get("uses_provisions") or []
|
||||||
|
],
|
||||||
}
|
}
|
||||||
for provision in record.get("provisions") or []
|
for provision in record.get("provisions") or []
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue