diff --git a/Makefile b/Makefile index 1b0ebea..7414fa9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test forecast variance control-cycle exchange-forecast exchange-usage optimization portfolio-report settlement thresholds +.PHONY: test forecast variance control-cycle exchange-forecast exchange-usage optimization portfolio-report settlement capability consumption-mode thresholds test: python3 -m unittest discover -s tests -p 'test_*.py' @@ -34,6 +34,15 @@ optimization: portfolio-report: python3 tools/portfolio_report.py . +capability: + python3 tools/capability.py + +consumption-mode: + @test -n "$(PERIOD)" || { echo 'PERIOD=YYYY-MM is required' >&2; exit 2; } + python3 tools/settlement.py consumption-mode --period $(PERIOD) \ + > data/consumption-mode/current.json + @echo "wrote data/consumption-mode/current.json for $(PERIOD)" + settlement: @test -n "$(PERIOD)" || { echo 'PERIOD=YYYY-MM is required' >&2; exit 2; } python3 tools/settlement.py close --period $(PERIOD) diff --git a/data/capability/platform-audit-storage.json b/data/capability/platform-audit-storage.json new file mode 100644 index 0000000..26ed2a1 --- /dev/null +++ b/data/capability/platform-audit-storage.json @@ -0,0 +1,217 @@ +{ + "schema_version": "0.1", + "record_scope": "operational", + "canon": { + "model": "ITC-CAP", + "model_version": "0.2.0", + "canon_version": "0.3.0", + "catalog": "info-tech-canon/infospace/models/capability/capabilities.yaml" + }, + "record_id": "capability-case:platform-audit-storage:2026-08", + "created_at": "2026-08-15T00:00:00Z", + "subject": "PostgreSQL backup for rapp-postgres/platform-pg, restated in canon terms", + "note": "Real provisioned resource. Every value carries an evidence basis; a derived value is only as strong as its weakest input.", + + "requires": [ + { + "consumer": "rapp-postgres/platform-pg", + "capability": "data.backup", + "profile": "database", + "minimum_maturity": "D5", + "targets": { + "rpo": { "value": 5, "unit": "minutes" }, + "rto": { "value": 60, "unit": "minutes" }, + "retention": { "value": 30, "unit": "days" } + }, + "constraints": [ + { + "dimension": "geographical_separation", + "predicate": "not_in", + "of": [ + { "kind": "host", "id": "railiance01" }, + { "kind": "provider", "id": "host-europe" } + ] + } + ], + "requirement_note": "The constraint is the one that decided the procurement: the backup must not share a failure domain with the host it protects. Before ITC-CAP 0.2.0 this lived only in prose." + } + ], + + "provisions": [ + { + "provider": "resource:platform:audit-storage", + "capability": "data.object", + "environment": "production", + "maturity": "D4", + "implements": "Scaleway Standard Multi-AZ object storage, nl-ams", + "resource_ref": "data/resources/platform-audit-storage.json", + "maturity_rationale": "Approved for production dependency and in production use since 2026-08-14. Not D5: object-integrity testing is inherited from the backup restore drill rather than measured on this provision.", + "consumes": [ + { + "class": "S", + "name": "stored objects", + "quantity": { "value": 0.0066, "unit": "GB" }, + "period": "month", + "basis": "measured", + "evidence": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#archive", + "observed_at": "2026-08-14", + "measurement_note": "8 objects, 6 604 031 B in prefix platform-pg/, four hours after commissioning" + }, + { + "class": "N", + "name": "restore egress", + "quantity": { "value": null, "unit": "GB" }, + "period": "month", + "basis": "unknown", + "gap": "two scratch restores read from the bucket but bytes transferred were not recorded (owner: resource-control, add to the next drill)" + }, + { + "class": "P", + "name": "object-store service", + "quantity": { "value": 1, "unit": "unit" }, + "period": "month", + "supply": "external", + "basis": "quoted", + "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az", + "measurement_note": "Published list price; no account quote and no invoice yet" + }, + { + "class": "H", + "name": "object-store operation", + "quantity": { "value": 1.0, "unit": "hour" }, + "period": "month", + "supply": "internal", + "basis": "assumed", + "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az.operator_hours_per_month", + "measurement_note": "A planning allowance chosen at model time, not an observed or recorded figure" + }, + { + "class": "I", + "name": "intelligence", + "quantity": { "value": null, "unit": "token" }, + "period": "month", + "basis": "unknown", + "gap": "resource-control does not yet meter token consumption against a provision (owner: resource-control)" + } + ] + }, + { + "provider": "rapp-postgres/platform-pg CNPG barmanObjectStore", + "capability": "data.backup", + "profile": "database", + "environment": "production", + "maturity": "D4", + "implements": "CloudNativePG 1.25 / Barman, PostgreSQL 16.10", + "depends_on_provision": "resource:platform:audit-storage provides data.object", + "maturity_rationale": "D4 and not D5. The requirement asks for D5 (reliability measured and actively controlled). Thresholds are declared in data/thresholds/platform-audit-storage.json, but the evidence behind them is one backup, one full restore, one PITR, and about four hours of operation. wal_archive_gap_minutes is declared and not yet emitted. Reliability is therefore claimed and not yet substantiated. The requirement is not met; the gap is stated rather than the maturity inflated.", + "evidence": [ + { + "hook": "successful_backup", + "basis": "measured", + "value": "platform-pg-daily-20260814181421 completed in 48 s, method barmanObjectStore, verified", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#first_backup" + }, + { + "hook": "successful_restore_test", + "basis": "measured", + "value": "full restore to scratch cluster in 65 s; audit_core.events 30 = 30; PITR to a corrected target in 65 s with keep marker present and destroy absent", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#full_restore,pitr" + }, + { + "hook": "measured_rpo", + "basis": "measured", + "value": "WAL segment archived about 2 s after pg_switch_wal, against a 5-minute archive_timeout", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#rpo_notes", + "caveat": "A single observation, not a distribution. The target is met on the evidence available." + }, + { + "hook": "measured_rto", + "basis": "measured", + "value": "65 s full-restore RTO, 65 s PITR RTO, on 636 MB of PGDATA", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json" + } + ], + "consumes": [ + { + "class": "H", + "name": "backup operation", + "quantity": { "value": null, "unit": "hour" }, + "period": "month", + "supply": "internal", + "basis": "unknown", + "gap": "real operator time was spent on procurement, credential custody, and two restore drills in this period, but no time record was kept (owner: resource-control)" + }, + { + "class": "P", + "name": "credential custody", + "quantity": { "value": 1, "unit": "unit" }, + "period": "month", + "supply": "internal", + "basis": "measured", + "evidence": "OpenBao ClusterSecretStore openbao-backup-object-storage, ExternalSecret synced to databases/platform-pg-backup-s3", + "observed_at": "2026-08-14", + "measurement_note": "security.secrets is a separate capability; this row records only that this provision consumes it" + }, + { + "class": "I", + "name": "intelligence", + "quantity": { "value": null, "unit": "token" }, + "period": "month", + "basis": "unknown", + "gap": "not yet metered (owner: resource-control)" + } + ] + } + ], + + "modelled_alternatives": { + "note": "The provider comparison that produced the decision, graded by evidence basis. The euro figures look precise and are not evidenced: they are derived from quoted list prices, projected demand, and an assumed labour rate.", + "review_period": "2026-08", + "values": [ + { + "name": "scaleway.infrastructure_eur_month", + "value": 7.35, + "basis": "derived", + "derived_from": [ + { "name": "scaleway.storage_eur_per_gb_month", "value": 0.01606, "basis": "quoted", "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az" }, + { "name": "stored_gb.month12", "value": 457.968, "basis": "projected", "evidence": "data/demand/platform-audit-storage.json base scenario, month 12" } + ] + }, + { + "name": "scaleway.labour_eur_month", + "value": 60.0, + "basis": "derived", + "derived_from": [ + { "name": "scaleway.operator_hours_per_month", "value": 1.0, "basis": "assumed", "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az" }, + { "name": "operator_hourly_eur", "value": 60, "basis": "assumed", "evidence": "data/demand/platform-audit-storage.json scenarios[*].operator_hourly_eur" } + ] + }, + { + "name": "hetzner.infrastructure_eur_month", + "value": 6.49, + "basis": "quoted", + "evidence": "data/providers/object-storage.json#hetzner-object-storage monthly_minimum_eur" + }, + { + "name": "hetzner.labour_eur_month", + "value": 90.0, + "basis": "derived", + "derived_from": [ + { "name": "hetzner.operator_hours_per_month", "value": 1.5, "basis": "assumed", "evidence": "data/providers/object-storage.json#hetzner-object-storage" }, + { "name": "operator_hourly_eur", "value": 60, "basis": "assumed", "evidence": "data/demand/platform-audit-storage.json scenarios[*].operator_hourly_eur" } + ] + } + ] + }, + + "open_items": [ + "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 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." + ] +} diff --git a/data/consumption-mode/current.json b/data/consumption-mode/current.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/consumption-mode/current.json @@ -0,0 +1 @@ +[] diff --git a/docs/evidence-basis.md b/docs/evidence-basis.md new file mode 100644 index 0000000..5c36500 --- /dev/null +++ b/docs/evidence-basis.md @@ -0,0 +1,103 @@ +# Evidence basis — where we know, and where we guess + +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**, +so a decision can be graded by the weakest thing it actually rests on. + +Vocabulary and propagation: `tools/basis.py`. + +## The scale + +Ordered strongest to weakest. The order is the point — it is what makes +"weakest input wins" computable. + +| Basis | Meaning | Example here | +|---|---|---| +| `invoiced` | A booked financial fact, authoritative from fin-hub | none yet | +| `measured` | Directly observed from the authoritative system | 8 objects, 6 604 031 B in the backup prefix | +| `quoted` | Stated by a provider or counterparty in a citable source | Scaleway €0.01606/GB-month | +| `derived` | Computed from other values by a stated rule | €7.35/month infrastructure | +| `projected` | Interpolated between, or extrapolated beyond, observations | 457.968 GB stored at month 12 | +| `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 +or contracted fact. Everything below them is inference. + +## The propagation rule + +> A derived value is only as strong as its weakest input. + +Without this rule a precise-looking figure launders weak assumptions. The +monthly labour line for the backup store is €60.00, which reads like a +measurement. It is `derived` from an `assumed` operator-hour allowance and an +`assumed` hourly rate, so it resolves to **`assumed`** — the weakest class of +input we have. + +Deriving in the other direction is not penalised: GB computed from measured +bytes resolves to `measured`, because both inputs are. + +## Two orthogonal qualifiers + +**`proxy_for`** — the value is a genuine measurement of a *different* quantity +than the one being reasoned about. It does not weaken the measurement; it +weakens the inference. The apps-pg allocation driver measures database bytes +accurately, and one of those databases has never recorded a transaction, so as +a driver for cost it is a proxy whose validity is separate from its precision. + +**`observed_at`** — a measurement has an age. A single sample from a +four-hour-old resource is `measured` and still thin. + +## Unknown is never zero + +A value with basis `unknown` carries no quantity and must name the gap and its +owner. A value with any other basis must carry a quantity. Zero means "measured +and none"; unknown means "not measured". The validator enforces both directions. + +## Decision grades + +`decision_grade(values)` reports the distribution, the weakest basis, the +evidenced ratio, and a grade: + +| Grade | Condition | +|---|---| +| `evidenced` | Every load-bearing value is invoiced, measured, 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 | + +### The first real application + +The provider comparison that selected Scaleway over Hetzner — a €29.14/month +difference stated to the cent — grades **`indicative`**: + +``` +by_basis: {quoted: 1, projected: 1, assumed: 2} +weakest: assumed +evidenced_ratio: 0.25 +``` + +One of four load-bearing values is evidenced. The two labour figures, which are +what actually inverted the ranking, are both `assumed` on top of `assumed`. + +This does not overturn the decision. The direction is robust — Hetzner needs +more operator time under any plausible rate, and the alternative was five times +dearer. But it does say plainly that the *magnitude* is a model output, not a +measurement, and that the cheapest way to strengthen it is to record real +operator hours rather than to refine the arithmetic. + +That is the whole purpose of the scale: to make the difference between a firm +conclusion and a confident-looking one visible before it is acted on. + +## Where it applies today + +- `data/capability/*.json` — every consumption row and evidence item. +- Provider comparisons carried in a capability restatement. + +Not yet retrofitted: `data/actuals/`, `data/control-cycle/`, and the option +fields of `data/optimization/`. Those already distinguish known from unknown and +carry `measurement_gaps` and `uncertainty` notes, but they do not yet grade a +present value. That is the next step, and it is the one that would let +`make thresholds` report a breach alongside the strength of the evidence +producing it. diff --git a/docs/evidence/RESOURCE-canon-restatement-2026-08-15.md b/docs/evidence/RESOURCE-canon-restatement-2026-08-15.md new file mode 100644 index 0000000..bea6490 --- /dev/null +++ b/docs/evidence/RESOURCE-canon-restatement-2026-08-15.md @@ -0,0 +1,98 @@ +# Backup case restated in ITC-CAP terms — 2026-08-15 + +## Result + +info-tech-canon accepted `demand/CapabilityProvisionEconomics.md` in full and +shipped canon 0.3.0 / ITC-CAP 0.2.0. They asked resource-control to restate the +backup case against the new shape, and said they would treat that restatement as +the real-provision proof for their §10 promotion requirement 3 rather than +inventing a worked example. + +Delivered: + +- `data/capability/platform-audit-storage.json` — the restatement +- `tools/capability.py`, `make capability` — validation against the live catalog +- `tools/basis.py`, `docs/evidence-basis.md` — the evidence-basis vocabulary +- 44 tests across `tests/test_capability.py` and `tests/test_basis.py` + +`tools/capability.py` reads +`info-tech-canon/infospace/models/capability/capabilities.yaml` directly rather +than copying it. Every capability id, profile, quality dimension, maturity level, +resource class, native unit, and constraint predicate we use is checked against +what the canon actually declares, so drift in either repository fails here. + +## What the restatement says + +**The requirement is now expressible.** The constraint that decided the +procurement — the backup must not sit in the failure domain of the host it +protects — was prose in an acceptance-requirements section. It is now: + +```yaml +constraints: + - dimension: geographical_separation + predicate: not_in + of: [{kind: host, id: railiance01}, {kind: provider, id: host-europe}] +``` + +**Two provisions, not one.** `resource:platform:audit-storage` provides +`data.object`; the CNPG/Barman configuration provides `data.backup` and depends +on it. This is the layering our resource records already had and could not name. + +**The requirement is not met, and the record says so.** The requirement asks for +`data.backup` at D5. The provision is D4. Thresholds are declared, but the +evidence behind them is one backup, one full restore, one PITR, and about four +hours of operation; `wal_archive_gap_minutes` is declared and not yet emitted. +Reliability is claimed, not substantiated. The review reports +`status: below_requirement` rather than inflating the maturity to match. + +**All four `data.backup` evidence hooks are satisfied** — +`successful_backup`, `successful_restore_test`, `measured_rpo`, `measured_rto` — +each `measured`, each pointing at the T05 evidence file. The `data.object` +provision is honestly incomplete: it satisfies neither `object_integrity_tests` +nor `availability_metrics`. + +**Consumption is in native units.** Storage in GB, human effort in hours, tokens +for intelligence, and `P` for the purchased service — with unknown recorded as +unknown and never as zero. Two rows the old model could not have carried: + +- `H` on the `data.backup` provision is `unknown`, because real operator time + was spent on procurement, credential custody, and two restore drills and no + time record was kept. A time record starts in 2026-09. +- `I` is `unknown` on both provisions. We do not meter tokens against a + provision yet. + +## The evidence-basis addition + +Beyond what the canon asked for, every value in the restatement declares how it +was obtained, on an ordered scale from `invoiced` through `measured`, `quoted`, +`derived`, `projected`, `estimated`, `assumed`, to `unknown`. A derived value +resolves to the weakest basis among its inputs. + +The rule earns its place immediately. The provider comparison that produced the +decision — Scaleway over Hetzner by €29.14/month, stated to the cent — grades: + +| | | +|---|---| +| by basis | `quoted` 1, `projected` 1, `assumed` 2 | +| weakest | `assumed` | +| evidenced ratio | 0.25 | +| **grade** | **`indicative`** | + +One of four load-bearing values is evidenced. The two labour figures — the ones +that actually inverted the ranking — are `derived` from an `assumed` hour count +and an `assumed` rate, so they resolve to `assumed`. + +This does not overturn the decision. The direction is robust under any plausible +rate, and the remaining alternative was five times dearer. It does say that the +*magnitude* is a model output rather than a measurement, and it identifies the +cheapest way to strengthen it: record real operator hours, not better arithmetic. + +Note that this is a finding about our own most consequential decision, produced +by a mechanism that did not exist yesterday. It is the intended effect. + +## Boundary held + +No canon artifact was copied into this repository, and nothing was written into +info-tech-canon for this restatement. The capability catalog stays theirs; the +provisioned facts stay ours; the join is by capability id, which is exactly the +durable interface CAP-R5 describes and the reason the canon was not split. diff --git a/tests/test_basis.py b/tests/test_basis.py new file mode 100644 index 0000000..f70b31c --- /dev/null +++ b/tests/test_basis.py @@ -0,0 +1,147 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1] / "tools")) +from basis import ( + BASIS_ORDER, + decision_grade, + is_evidenced, + profile, + rank, + resolve, + strongest, + validate_value, + weakest, +) + + +def value(basis="measured", **overrides): + result = {"name": "v", "basis": basis, "value": 1} + if basis == "unknown": + result = {"name": "v", "basis": basis, "value": None, "gap": "not measured (owner: x)"} + result.update(overrides) + return result + + +class OrderTest(unittest.TestCase): + def test_order_runs_strongest_to_weakest(self): + self.assertEqual("invoiced", BASIS_ORDER[0]) + self.assertEqual("unknown", BASIS_ORDER[-1]) + self.assertLess(rank("measured"), rank("estimated")) + self.assertLess(rank("estimated"), rank("assumed")) + + def test_weakest_and_strongest_pick_opposite_ends(self): + bases = ["measured", "assumed", "quoted"] + self.assertEqual("assumed", weakest(bases)) + self.assertEqual("measured", strongest(bases)) + + def test_empty_collection_is_unknown_not_an_error(self): + self.assertEqual("unknown", weakest([])) + + def test_only_observed_or_contracted_bases_count_as_evidenced(self): + for basis in ("invoiced", "measured", "quoted"): + self.assertTrue(is_evidenced(basis)) + for basis in ("derived", "projected", "estimated", "assumed", "unknown"): + self.assertFalse(is_evidenced(basis)) + + def test_unknown_basis_name_is_rejected(self): + with self.assertRaises(ValueError): + rank("vibes") + + +class PropagationTest(unittest.TestCase): + """A derived value is only as strong as its weakest input.""" + + def test_derivation_from_measured_inputs_stays_measured(self): + derived = value("derived", derived_from=[value("measured"), value("measured")]) + self.assertEqual("measured", resolve(derived)) + + def test_one_assumed_input_drags_the_result_down(self): + derived = value("derived", derived_from=[value("quoted"), value("assumed")]) + self.assertEqual("assumed", resolve(derived)) + + def test_propagation_is_recursive(self): + inner = value("derived", derived_from=[value("measured"), value("estimated")]) + outer = value("derived", derived_from=[value("measured"), inner]) + self.assertEqual("estimated", resolve(outer)) + + def test_derived_without_inputs_is_rejected(self): + with self.assertRaisesRegex(ValueError, "derived_from"): + resolve(value("derived")) + + def test_non_derived_value_resolves_to_itself(self): + self.assertEqual("quoted", resolve(value("quoted"))) + + +class ValidationTest(unittest.TestCase): + def test_unknown_must_not_carry_a_quantity(self): + with self.assertRaises(ValueError): + validate_value({"basis": "unknown", "value": 0, "gap": "g"}) + + def test_unknown_must_name_the_gap_and_owner(self): + with self.assertRaisesRegex(ValueError, "name the gap"): + validate_value({"basis": "unknown", "value": None}) + + def test_known_basis_must_carry_a_quantity(self): + with self.assertRaisesRegex(ValueError, "must carry a quantity"): + validate_value({"basis": "measured", "value": None}) + + def test_zero_is_a_measurement_not_an_absence(self): + validate_value({"basis": "measured", "value": 0}) + + def test_derived_from_only_allowed_on_derived(self): + with self.assertRaisesRegex(ValueError, "only a derived value"): + validate_value(value("measured", derived_from=[value()])) + + def test_empty_proxy_target_is_rejected_but_absent_one_is_fine(self): + with self.assertRaisesRegex(ValueError, "proxy_for"): + validate_value(value("measured", proxy_for=" ")) + validate_value(value("measured", proxy_for=None)) + validate_value(value("measured", proxy_for="cost of the shared host")) + + +class DecisionGradeTest(unittest.TestCase): + def test_all_evidenced_values_grade_evidenced(self): + result = decision_grade([value("measured"), value("invoiced"), value("quoted")]) + self.assertEqual("evidenced", result["grade"]) + self.assertEqual(1.0, result["evidenced_ratio"]) + + def test_a_single_assumption_makes_the_whole_decision_indicative(self): + result = decision_grade([value("measured"), value("measured"), value("assumed")]) + self.assertEqual("indicative", result["grade"]) + self.assertEqual("assumed", result["weakest"]) + + def test_an_unknown_makes_the_decision_insufficient(self): + result = decision_grade([value("measured"), value("unknown")]) + self.assertEqual("insufficient", result["grade"]) + + def test_precise_arithmetic_does_not_upgrade_weak_inputs(self): + """The provider comparison: a two-decimal euro figure built on assumptions.""" + euros = value( + "derived", + name="labour_eur_month", + value=60.0, + derived_from=[value("assumed", name="hours"), value("assumed", name="rate")], + ) + result = decision_grade([value("quoted", name="price"), euros]) + self.assertEqual("assumed", result["weakest"]) + self.assertEqual("indicative", result["grade"]) + + def test_projected_values_grade_separately_from_estimates(self): + result = decision_grade([value("measured"), value("projected")]) + self.assertEqual("projected", result["grade"]) + + def test_proxies_are_reported_in_the_note(self): + result = decision_grade([value("measured", proxy_for="cost of the shared host")]) + self.assertEqual(1, len(result["proxies"])) + self.assertIn("proxy", result["note"]) + + def test_profile_counts_by_basis_in_canonical_order(self): + result = profile([value("assumed"), value("measured"), value("measured")]) + self.assertEqual(["measured", "assumed"], list(result["by_basis"])) + self.assertEqual(2, result["by_basis"]["measured"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_capability.py b/tests/test_capability.py new file mode 100644 index 0000000..6e66996 --- /dev/null +++ b/tests/test_capability.py @@ -0,0 +1,167 @@ +import json +import sys +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / "tools")) +from capability import ( + consumption_profile, + evidence_coverage, + load_canon, + review, + validate_provision, + validate_requirement, +) + +RECORD = json.loads((ROOT / "data/capability/platform-audit-storage.json").read_text()) + + +def canon(): + return load_canon() + + +class CanonBindingTest(unittest.TestCase): + """We read the canon rather than copying it; drift must fail here.""" + + def setUp(self): + self.canon = canon() + + def test_catalog_is_the_version_we_restated_against(self): + self.assertEqual("0.2.0", self.canon["version"]) + self.assertEqual("0.3.0", self.canon["canon_version"]) + + def test_human_effort_and_intelligence_classes_exist_with_native_units(self): + classes = self.canon["resource_classes"] + self.assertEqual("hour", classes["H"]["native_unit"]) + self.assertEqual("internal", classes["H"]["supply"]) + self.assertEqual("constrained", classes["H"]["capacity_behaviour"]) + self.assertEqual("token", classes["I"]["native_unit"]) + + def test_unknown_capability_is_rejected(self): + with self.assertRaisesRegex(ValueError, "unknown capability"): + validate_requirement({"capability": "data.telepathy"}, self.canon) + + +class RequirementTest(unittest.TestCase): + def setUp(self): + self.canon = canon() + self.requirement = deepcopy(RECORD["requires"][0]) + + def test_the_real_requirement_validates(self): + validate_requirement(self.requirement, self.canon) + + def test_target_key_must_be_a_declared_quality_dimension(self): + self.requirement["targets"]["chattiness"] = {"value": 1, "unit": "x"} + with self.assertRaisesRegex(ValueError, "quality dimension"): + validate_requirement(self.requirement, self.canon) + + def test_constraint_dimension_must_be_declared(self): + self.requirement["constraints"][0]["dimension"] = "vibes" + with self.assertRaisesRegex(ValueError, "quality dimension"): + validate_requirement(self.requirement, self.canon) + + def test_predicate_must_be_in_the_closed_set(self): + self.requirement["constraints"][0]["predicate"] = "sort_of_near" + with self.assertRaisesRegex(ValueError, "closed set"): + validate_requirement(self.requirement, self.canon) + + def test_profile_must_be_declared_on_that_capability(self): + self.requirement["profile"] = "volume-of-vibes" + with self.assertRaisesRegex(ValueError, "profile"): + validate_requirement(self.requirement, self.canon) + + def test_the_constraint_that_decided_procurement_is_expressible(self): + constraint = self.requirement["constraints"][0] + self.assertEqual("geographical_separation", constraint["dimension"]) + self.assertEqual("not_in", constraint["predicate"]) + self.assertIn("railiance01", [entry["id"] for entry in constraint["of"]]) + + +class ProvisionTest(unittest.TestCase): + def setUp(self): + self.canon = canon() + self.provisions = {p["capability"]: deepcopy(p) for p in RECORD["provisions"]} + + def test_both_real_provisions_validate(self): + for provision in self.provisions.values(): + validate_provision(provision, self.canon) + + def test_consumption_unit_must_be_the_class_native_unit(self): + provision = self.provisions["data.object"] + row = next(r for r in provision["consumes"] if r["class"] == "H") + row["quantity"]["unit"] = "eur" + with self.assertRaisesRegex(ValueError, "native unit"): + validate_provision(provision, self.canon) + + def test_currency_cannot_be_smuggled_in_as_a_class(self): + provision = self.provisions["data.object"] + provision["consumes"].append( + {"class": "EUR", "quantity": {"value": 7.35, "unit": "eur"}, "basis": "quoted"} + ) + with self.assertRaisesRegex(ValueError, "unknown resource class"): + validate_provision(provision, self.canon) + + def test_unknown_consumption_may_not_be_recorded_as_zero(self): + provision = self.provisions["data.backup"] + row = next(r for r in provision["consumes"] if r["basis"] == "unknown") + row["quantity"]["value"] = 0 + with self.assertRaises(ValueError): + validate_provision(provision, self.canon) + + def test_evidence_hook_must_be_declared_on_the_capability(self): + provision = self.provisions["data.backup"] + provision["evidence"][0]["hook"] = "vibes_check" + with self.assertRaisesRegex(ValueError, "evidence hook"): + validate_provision(provision, self.canon) + + def test_duplicate_class_rows_are_rejected(self): + provision = self.provisions["data.backup"] + provision["consumes"].append(deepcopy(provision["consumes"][0])) + with self.assertRaisesRegex(ValueError, "duplicate"): + validate_provision(provision, self.canon) + + def test_backup_provision_satisfies_all_four_declared_evidence_hooks(self): + coverage = evidence_coverage(self.provisions["data.backup"], self.canon) + self.assertTrue(coverage["complete"]) + self.assertEqual([], coverage["missing"]) + self.assertEqual(4, len(coverage["supplied"])) + + def test_object_provision_is_honest_about_missing_hooks(self): + coverage = evidence_coverage(self.provisions["data.object"], self.canon) + self.assertFalse(coverage["complete"]) + self.assertIn("object_integrity_tests", coverage["missing"]) + + def test_effort_and_tokens_are_recorded_in_native_units(self): + rows = {r["class"]: r for r in self.provisions["data.object"]["consumes"]} + self.assertEqual("hour", rows["H"]["quantity"]["unit"]) + self.assertEqual("token", rows["I"]["quantity"]["unit"]) + self.assertEqual("unknown", rows["I"]["basis"]) + + +class ReviewTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.report = review(deepcopy(RECORD), canon()) + + def test_requirement_is_reported_as_below_the_asked_maturity(self): + requirement = self.report["requirements"][0] + self.assertEqual("D5", requirement["required"]) + self.assertEqual("D4", requirement["provided"]) + self.assertEqual("below_requirement", requirement["status"]) + + def test_provider_comparison_grades_as_indicative_not_evidenced(self): + grade = self.report["alternatives_grade"] + self.assertEqual("indicative", grade["grade"]) + self.assertEqual("assumed", grade["weakest"]) + self.assertEqual(0.25, grade["evidenced_ratio"]) + + def test_consumption_profiles_report_what_is_still_unknown(self): + for provision in self.report["provisions"]: + self.assertEqual("unknown", provision["consumption"]["weakest"]) + self.assertGreater(provision["consumption"]["count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/basis.py b/tools/basis.py new file mode 100644 index 0000000..9cd259a --- /dev/null +++ b/tools/basis.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""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. +A counted object and an assumed hourly rate are both numbers; they are not both +knowledge. This module names the difference and propagates it. + +The central rule is that a derived value is only as strong as its weakest +input. Without it, a precise-looking figure launders weak 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 pathlib import Path + +# Ordered strongest to weakest. The order is the whole point: it is what makes +# "weakest input wins" computable. +BASIS_ORDER = ( + "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) +_RANK = {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: + if basis not in _RANK: + raise ValueError(f"unknown evidence basis {basis!r}") + return _RANK[basis] + + +def weakest(bases) -> str: + """The weakest basis in a collection. Empty means nothing is known.""" + bases = list(bases) + if not bases: + return "unknown" + return max(bases, key=rank) + + +def strongest(bases) -> str: + bases = list(bases) + if not bases: + return "unknown" + return min(bases, key=rank) + + +def is_evidenced(basis: str) -> bool: + """True when the value asserts an observed or contracted fact.""" + return basis in EVIDENCED + + +def resolve(value: dict) -> str: + """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. + """ + basis = value.get("basis", "unknown") + if basis not in 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) for item in inputs) + + +def validate_value(value: dict) -> None: + basis = value.get("basis") + if basis not in 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") + # 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") + 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) + + +def profile(values) -> dict: + """Summarize a set of values for decision review.""" + resolved = [] + proxies = [] + for value in values: + validate_value(value) + effective = resolve(value) + resolved.append(effective) + 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)] + return { + "count": len(resolved), + "by_basis": {k: counts[k] for k in BASIS_ORDER if k in counts}, + "weakest": weakest(resolved), + "evidenced": len(evidenced), + "evidenced_ratio": round(len(evidenced) / len(resolved), 4) if resolved else None, + "proxies": proxies, + } + + +def decision_grade(values) -> dict: + """Grade a decision by the weakest evidence it actually rests on. + + A conclusion is not stronger than its weakest load-bearing input, however + precise the arithmetic between them looks. + """ + summary = profile(values) + basis = summary["weakest"] + if basis == "unknown": + grade, note = "insufficient", "at least one load-bearing value is unknown" + elif is_evidenced(basis): + 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"]: + 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()) diff --git a/tools/capability.py b/tools/capability.py new file mode 100644 index 0000000..7d2fdee --- /dev/null +++ b/tools/capability.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Validate capability requirements and provisions against the live ITC-CAP catalog. + +resource-control does not copy the canon. It reads `capabilities.yaml` from +info-tech-canon and checks that every capability id, profile, quality dimension, +maturity level, resource class, and predicate this repository uses is one the +canon actually declares. A drift in either repository fails here rather than +silently producing a record the canon would reject. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import basis as basis_module + +CANON_PATHS = ( + Path("/home/worsch/info-tech-canon/infospace/models/capability/capabilities.yaml"), + Path("../info-tech-canon/infospace/models/capability/capabilities.yaml"), +) +PREDICATES = frozenset({"not_in", "in", "equals", "lte", "gte"}) + + +def _parse_yaml(text: str) -> dict: + try: + import yaml + except ModuleNotFoundError: # pragma: no cover - yaml is present in this env + raise RuntimeError("PyYAML is required to read the canon catalog") + return yaml.safe_load(text) + + +def load_canon(paths=CANON_PATHS) -> dict: + for path in paths: + if path.exists(): + catalog = _parse_yaml(path.read_text()) + capabilities = { + cap["id"]: cap + for domain in catalog["domains"] + for cap in domain.get("capabilities", []) + } + return { + "version": catalog["canon"]["version"], + "canon_version": catalog["canon"]["canon_version"], + "capabilities": capabilities, + "resource_classes": {rc["id"]: rc for rc in catalog["resource_classes"]}, + "maturity_levels": {m["id"] if isinstance(m, dict) else m for m in catalog["maturity_levels"]}, + "source": str(path), + } + raise FileNotFoundError("ITC-CAP catalog not found; is info-tech-canon checked out?") + + +def validate_requirement(requirement: dict, canon: dict) -> None: + cap_id = requirement["capability"] + capability = canon["capabilities"].get(cap_id) + if capability is None: + raise ValueError(f"unknown capability {cap_id}") + + profile = requirement.get("profile") + if profile and profile not in (capability.get("profiles") or []): + raise ValueError(f"{cap_id} does not declare profile {profile!r}") + + maturity = requirement.get("minimum_maturity") + if maturity and maturity not in canon["maturity_levels"]: + raise ValueError(f"unknown maturity level {maturity!r}") + + dimensions = set(capability.get("quality_dimensions") or []) + # CAP-R9: a target key or constraint dimension must be declared on the + # capability. This is what stops a consumer inventing quality vocabulary. + for key in (requirement.get("targets") or {}): + if key not in dimensions: + raise ValueError(f"{cap_id} does not declare quality dimension {key!r}") + for constraint in requirement.get("constraints") or []: + if constraint["dimension"] not in dimensions: + raise ValueError(f"{cap_id} does not declare quality dimension {constraint['dimension']!r}") + if constraint["predicate"] not in PREDICATES: + raise ValueError(f"predicate {constraint['predicate']!r} is not in the closed set") + if not constraint.get("of"): + raise ValueError("a constraint must name what it applies to") + + +def validate_provision(provision: dict, canon: dict) -> None: + cap_id = provision["capability"] + capability = canon["capabilities"].get(cap_id) + if capability is None: + raise ValueError(f"unknown capability {cap_id}") + if provision["maturity"] not in canon["maturity_levels"]: + raise ValueError(f"unknown maturity level {provision['maturity']!r}") + profile = provision.get("profile") + if profile and profile not in (capability.get("profiles") or []): + raise ValueError(f"{cap_id} does not declare profile {profile!r}") + + hooks = set(capability.get("evidence_hooks") or []) + for item in provision.get("evidence") or []: + if item["hook"] not in hooks: + raise ValueError(f"{cap_id} does not declare evidence hook {item['hook']!r}") + basis_module.rank(item["basis"]) + + seen = set() + for row in provision.get("consumes") or []: + klass = row["class"] + if klass not in canon["resource_classes"]: + raise ValueError(f"unknown resource class {klass!r}") + if klass in seen: + raise ValueError(f"duplicate consumption row for class {klass}") + seen.add(klass) + + declared = canon["resource_classes"][klass] + unit = row["quantity"]["unit"] + if unit != declared["native_unit"]: + raise ValueError( + f"class {klass} native unit is {declared['native_unit']!r}, got {unit!r}" + ) + # Unknown is recorded as unknown, never as zero. + basis_module.validate_value({ + "basis": row["basis"], + "value": row["quantity"]["value"], + "gap": row.get("gap"), + "derived_from": row.get("derived_from"), + }) + if row.get("supply") and row["supply"] not in {"internal", "external"}: + raise ValueError(f"supply must be internal or external, got {row['supply']!r}") + + +def evidence_coverage(provision: dict, canon: dict) -> dict: + """Which of the capability's declared evidence hooks this provision satisfies.""" + capability = canon["capabilities"][provision["capability"]] + declared = list(capability.get("evidence_hooks") or []) + supplied = {item["hook"] for item in provision.get("evidence") or []} + return { + "declared": declared, + "supplied": sorted(supplied), + "missing": sorted(set(declared) - supplied), + "complete": not (set(declared) - supplied), + } + + +def consumption_profile(provision: dict) -> dict: + values = [ + { + "name": f"{row['class']}:{row.get('name', '')}", + "basis": row["basis"], + "value": row["quantity"]["value"], + "gap": row.get("gap"), + "derived_from": row.get("derived_from"), + "proxy_for": row.get("proxy_for"), + } + for row in provision.get("consumes") or [] + ] + return basis_module.profile(values) + + +def review(record: dict, canon: dict) -> dict: + for requirement in record.get("requires") or []: + validate_requirement(requirement, canon) + for provision in record.get("provisions") or []: + validate_provision(provision, canon) + + # Does each requirement actually have a provision that meets it? + provisions = {p["capability"]: p for p in record.get("provisions") or []} + met = [] + for requirement in record.get("requires") or []: + provision = provisions.get(requirement["capability"]) + wanted = requirement.get("minimum_maturity") + if provision is None: + met.append({"capability": requirement["capability"], "status": "unprovided"}) + continue + levels = sorted(canon["maturity_levels"]) + satisfied = levels.index(provision["maturity"]) >= levels.index(wanted) if wanted else True + met.append({ + "capability": requirement["capability"], + "required": wanted, + "provided": provision["maturity"], + "status": "met" if satisfied else "below_requirement", + }) + + return { + "record_id": record["record_id"], + "canon": {k: canon[k] for k in ("version", "canon_version", "source")}, + "requirements": met, + "provisions": [ + { + "capability": provision["capability"], + "maturity": provision["maturity"], + "evidence": evidence_coverage(provision, canon), + "consumption": consumption_profile(provision), + } + for provision in record.get("provisions") or [] + ], + "alternatives_grade": ( + basis_module.decision_grade(record["modelled_alternatives"]["values"]) + if record.get("modelled_alternatives") else None + ), + } + + +def main() -> int: + paths = sys.argv[1:] or sorted(str(p) for p in Path("data/capability").glob("*.json")) + canon = load_canon() + reports = [review(json.loads(Path(p).read_text()), canon) for p in paths] + print(json.dumps(reports, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/settlement.py b/tools/settlement.py index f0cb521..1821ebc 100644 --- a/tools/settlement.py +++ b/tools/settlement.py @@ -323,9 +323,31 @@ def close_live(root: Path, period: str) -> list[dict]: return close_fixture(payload, terms, register) +def consumption_mode_signal(statements: list[dict], period: str) -> list[dict]: + """Project settlement statements into the signal railiance-platform consumes. + + An empty list is a real answer, not a missing one: it means no entity is + restricted. Per the consumption-mode contract a *missing* signal is neither + open nor restricted and does not refuse, so publishing the empty list is + what makes "nobody is restricted" an assertion rather than an absence. + """ + return [ + { + "schema_version": "0.1", + "record_type": "consumption_mode", + "financial_entity_id": statement["financial_entity_id"], + "period": period, + "consumption_mode": statement["consumption_mode"], + "new_transfer_charges_allowed_eur": statement["next_month_allowance_eur"], + "terms_version": statement["terms_version"], + } + for statement in statements + ] + + def main() -> int: parser = argparse.ArgumentParser(description="Monthly settlement close") - parser.add_argument("command", choices=["close"]) + parser.add_argument("command", choices=["close", "consumption-mode"]) parser.add_argument("--period", help="YYYY-MM") parser.add_argument("--fixture", help="path to a settlement fixture") parser.add_argument("--root", default=str(ROOT)) @@ -341,6 +363,12 @@ def main() -> int: print("PERIOD=YYYY-MM is required without --fixture", file=sys.stderr) return 2 statements = close_live(root, args.period) + if args.command == "consumption-mode": + if not args.period: + print("--period YYYY-MM is required for consumption-mode", file=sys.stderr) + return 2 + print(json.dumps(consumption_mode_signal(statements, args.period), indent=2)) + return 0 print(json.dumps(statements, indent=2)) return 0 diff --git a/tools/validate.py b/tools/validate.py index 30d5330..2421985 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -8,7 +8,8 @@ from entities import REQUIRED_ENTITY_IDS, load_register, load_terms, require_ent from optimization import validate_case from portfolio import validate_record from portfolio_report import build as build_portfolio_report -from settlement import close_fixture, close_live +from capability import load_canon, review as review_capability +from settlement import close_fixture, close_live, consumption_mode_signal def load(path: str) -> dict: @@ -138,6 +139,25 @@ def main() -> int: assert any("reef-storage" in note for note in view.get("notes") or []) if view["reef_id"] == "reef-storage": assert view["role"] == "storage_substrate" + # Capability restatements bind to the live ITC-CAP catalog rather than a + # copy of it, so drift in either repository fails here. + canon = load_canon() + capability_records = [load(str(p)) for p in Path("data/capability").glob("*.json")] + assert capability_records, "no capability restatement present" + for record in capability_records: + report = review_capability(record, canon) + assert report["provisions"], f"{record['record_id']} declares no provision" + for provision in report["provisions"]: + # Unknown consumption must be named, never silently absent. + assert provision["consumption"]["count"] > 0 + backup = next( + p for r in capability_records for p in review_capability(r, canon)["provisions"] + if p["capability"] == "data.backup" + ) + assert backup["evidence"]["complete"], "the backup provision must satisfy every declared hook" + signal = consumption_mode_signal(close_live(Path("."), "2026-09"), "2026-09") + published = load("data/consumption-mode/current.json") + assert published == signal, "consumption-mode signal is stale; run make consumption-mode" print("resource-control declarations: valid") return 0