From 06b24355a75f2b5760afb03e07ec85e83ba79123 Mon Sep 17 00:00:00 2001 From: blocksifrdev Date: Tue, 15 Sep 2026 12:04:16 -0400 Subject: [PATCH] Correct the aggregation algorithm: issuer capping now binds (v1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementing the normative spec surfaced a defect in it. Step 5 capped a dominant issuer at max_issuer_weight and then re-normalized across all issuers, which handed the capped excess straight back whenever the other issuers were light. With 50 receipts from one issuer and one each from two others, the "capped" issuer still held 87% of the weight and the aggregate came out at 0.90. The cap only bound when the field was already balanced — the case where a cap is not needed. v1.1 redistributes a capped issuer's excess to the uncapped issuers, and applies effective_cap = max(max_issuer_weight, 1 / issuer_count), since a cap below 1/n cannot be satisfied. That input now gives the dominant issuer exactly 0.40 and an aggregate of 0.52. Vector agg-006 was written to assert exactly this intent — "4 good receipts from A cannot dominate 1 bad receipt from B" — and could not pass under v1.0. It passes under v1.1 unchanged, which is the clearest evidence that the vector recorded the intent and the algorithm had drifted from it. Vectors repaired and extended: - agg-003 contradicted itself: its _explanation worked the arithmetic and concluded 0.5 while expected said 0.4. It was already superseded by agg-003-corrected, which now carries the agg-003 id. - agg-008 recomputed from unrounded weights, 0.917 -> 0.918. - Added agg-009 (redistribution), agg-010 (the 1/n floor) and agg-011 (single issuer, nobody to redistribute to). All eleven vectors now pass on their own terms; the known-divergence scaffolding in the test suite is gone. This changes conformance: a v1.0 implementation produces different scores wherever one issuer exceeds the cap while the others are light. The spec carries that warning in step 5 and the changelog repeats it. 194 tests. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 35 ++ packages/pctr/README.md | 44 +- packages/pctr/src/aggregate.mjs | 39 +- packages/pctr/tests/aggregation.test.mjs | 55 +- protocol/aggregation-spec.md | 64 ++- .../test-vectors/aggregation-vectors.json | 534 ++++++++++++++++-- 6 files changed, 643 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4191cec..60aa992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 2026-09-15 + +### Changed — normative + +- **Trust score aggregation corrected to v1.1** (`protocol/aggregation-spec.md`). Step 5 + capped a dominant issuer's contribution and then re-normalized across *all* issuers, + which returned the capped excess to that issuer whenever the others were light: with 50 + receipts from one issuer and one each from two others, the "capped" issuer held 87% of + the weight and the aggregate was 0.90. The cap only bound when the field was already + balanced — the case where a cap is unnecessary. + + v1.1 redistributes a capped issuer's excess to the **uncapped** issuers, and applies + `effective_cap = max(max_issuer_weight, 1 / issuer_count)` since a cap below `1/n` + cannot be satisfied. The same input now yields 0.40 for the dominant issuer and an + aggregate of 0.52. + + **This changes conformance.** v1.0 implementations produce different scores wherever one + issuer exceeds the cap while others are light. Re-run the test vectors. + +- **Aggregation test vectors repaired and extended.** `agg-003` contradicted itself — its + `_explanation` worked the arithmetic and concluded 0.5 while `expected` said 0.4 — and + was superseded by its own corrected variant, which now carries the `agg-003` id. + `agg-008` was recomputed from unrounded weights (0.917 → 0.918). `agg-006` is unchanged + and now passes, having asserted an intent v1.0 could not deliver. Added `agg-009` + (redistribution), `agg-010` (the `1/n` floor) and `agg-011` (single issuer). + +### Added + +- First implementation of the normative aggregation algorithm, in + `packages/pctr/src/aggregate.mjs`, with all eleven vectors running in CI. +- `pctr attest`: trust measured from execution receipts and configured attestors rather + than read from `pctr.json`, emitting a TTP `TrustThresholdProof`. An agent with no + admissible evidence is `UNPROVEN` and cannot route to a protected consequence. + + ## 2026-04-25 ### Added diff --git a/packages/pctr/README.md b/packages/pctr/README.md index e492ffe..51a0cf7 100644 --- a/packages/pctr/README.md +++ b/packages/pctr/README.md @@ -350,25 +350,31 @@ Aggregation is the normative algorithm in negative-signal amplification, per-issuer weight capping — and `pctr attest` emits a TTP `TrustThresholdProof` naming the evidence it rests on. -### Known divergences in the aggregation vectors - -The spec ships nine test vectors; all nine run in `tests/aggregation.test.mjs`. Three do -not match the algorithm the document itself defines, and are asserted as **known -divergences** rather than skipped: - -| Vector | Expects | The written formula yields | Why | -| --- | --- | --- | --- | -| `agg-003` | 0.4 | **0.5** | Superseded by `agg-003-corrected` (identical receipts, expects 0.5). Its own `_explanation` field works the arithmetic, catches itself mid-sentence — *"wait let me recalculate"* — and concludes 0.5, while `expected` still says 0.4. | -| `agg-006` | 0.5 | **0.5799** | Expecting 0.5 requires *both* issuers capped at 0.40. B's uncapped fraction is 0.29, and step 5 says `min(fraction, max_issuer_weight)` — a cap, not a floor. | -| `agg-008` | 0.917 | **0.918** | Off by 0.0010, a hair outside the vectors' own ±0.001 tolerance; consistent with the expected value being computed from rounded intermediate weights. | - -There is also a substantive point behind `agg-006`. Step 5 caps a dominant issuer at 0.40 -and then **re-normalizes**, so when the other issuers carry little weight the capped -issuer still ends up with most of the vote — 50 perfect receipts from one issuer against -two bad ones from two others still yields ~0.90, with the "capped" issuer holding 87% of -the weight. The cap only bites when the rest of the field is comparable. That is pinned -by a test so it cannot be mistaken for an implementation bug, but the spec is what needs -the decision. +### The aggregation algorithm was corrected to v1.1 + +Implementing `protocol/aggregation-spec.md` surfaced a real defect in it, now fixed. + +v1.0's step 5 capped a dominant issuer at `max_issuer_weight` (0.40) and then +**re-normalized across all issuers** — which handed the capped issuer its excess straight +back whenever the others were light. With 50 receipts from one issuer and one each from +two others, the "capped" issuer still held **87%** of the weight and the score came out at +0.90. The cap only bit when the rest of the field was already comparable, which is exactly +the case where a cap isn't needed. + +v1.1 redistributes a capped issuer's excess to the **uncapped** issuers instead, and +applies `effective_cap = max(max_issuer_weight, 1 / issuer_count)` because a cap below +`1/n` is infeasible. That same input now gives the dominant issuer exactly 0.40 and a +score of **0.52**. + +Vector `agg-006` was written to assert precisely this intent — *"4 good receipts from A +cannot dominate 1 bad receipt from B"* — and did not pass under v1.0. It passes under +v1.1 unchanged. Two other vectors were repaired: `agg-003` contradicted itself and was +superseded by its own corrected variant, and `agg-008` was off by 0.0010 from rounded +intermediate weights. Three vectors were added covering redistribution, the `1/n` floor, +and the single-issuer case. All eleven pass. + +**This changes conformance.** A v1.0 implementation produces different scores wherever one +issuer exceeds the cap while the others are light. ## Execution authority diff --git a/packages/pctr/src/aggregate.mjs b/packages/pctr/src/aggregate.mjs index 6ec7c2a..301d9b9 100644 --- a/packages/pctr/src/aggregate.mjs +++ b/packages/pctr/src/aggregate.mjs @@ -1,6 +1,6 @@ // TTP TRUST SCORE AGGREGATION — the normative algorithm. // -// Implements protocol/aggregation-spec.md v1.0 step for step. That document is marked +// Implements protocol/aggregation-spec.md v1.1 step for step. That document is marked // normative and carries test vectors; tests/aggregation.test.mjs runs every one of them, // so this file is not free to drift from the spec. // @@ -8,7 +8,9 @@ // - Negative signals weigh more (default 1.5x). An agent behaving well most of the time // must not be able to average away a few dangerous actions. // - No single issuer may contribute more than a fraction of the score (default 0.40), -// so one chatty or captured issuer cannot decide an agent's trust alone. +// so one chatty or captured issuer cannot decide an agent's trust alone. v1.1 fixed +// this: v1.0 capped and then re-normalized across everyone, which handed the capped +// issuer its excess straight back whenever the other issuers were light. export const DEFAULT_PARAMS = { receipt_window_s: 300, @@ -63,29 +65,38 @@ export function aggregateTrust(receipts = [], current_time_ms = Date.now(), para issuer_raw_weight: e.totalWeight })); - // Step 5 — cap each issuer's fraction, then re-normalize. + // Step 5 — cap, then water-fill the excess onto the uncapped issuers. A cap below + // 1/n is infeasible, so that is the floor on the cap actually applied. const totalRawWeight = issuers.reduce((sum, i) => sum + i.issuer_raw_weight, 0); - const capped = issuers.map((i) => ({ - ...i, - capped_fraction: Math.min(i.issuer_raw_weight / totalRawWeight, max_issuer_weight) - })); - const normalizationFactor = capped.reduce((sum, i) => sum + i.capped_fraction, 0); + const effectiveCap = Math.max(max_issuer_weight, 1 / issuers.length); + const weights = issuers.map((i) => ({ ...i, weight: i.issuer_raw_weight / totalRawWeight, capped: false })); + + for (let pass = 0; pass <= weights.length; pass++) { + const over = weights.filter((i) => !i.capped && i.weight > effectiveCap + 1e-12); + if (!over.length) break; + + let excess = 0; + for (const i of over) { excess += i.weight - effectiveCap; i.weight = effectiveCap; i.capped = true; } + + const free = weights.filter((i) => !i.capped); + const freeTotal = free.reduce((sum, i) => sum + i.weight, 0); + if (!free.length || freeTotal === 0) break; + for (const i of free) i.weight += excess * (i.weight / freeTotal); + } // Step 6 — combine, and clamp for floating point. - const rawScore = capped.reduce( - (sum, i) => sum + i.issuer_score * (i.capped_fraction / normalizationFactor), 0 - ); + const rawScore = weights.reduce((sum, i) => sum + i.issuer_score * i.weight, 0); return { score: Math.max(0, Math.min(1, rawScore)), contributing_receipts: windowReceipts.length, contributing_issuers: issuers.length, oldest_receipt_age_s: Math.round(Math.max(...weighted.map((r) => r.age_s))), - issuers: capped.map((i) => ({ + issuers: weights.map((i) => ({ issuer_id: i.issuer_id, issuer_score: Number(i.issuer_score.toFixed(6)), - weight: Number((i.capped_fraction / normalizationFactor).toFixed(6)), - capped: i.issuer_raw_weight / totalRawWeight > max_issuer_weight + weight: Number(i.weight.toFixed(6)), + capped: i.capped })) }; } diff --git a/packages/pctr/tests/aggregation.test.mjs b/packages/pctr/tests/aggregation.test.mjs index e82c06d..a3e37ba 100644 --- a/packages/pctr/tests/aggregation.test.mjs +++ b/packages/pctr/tests/aggregation.test.mjs @@ -11,42 +11,7 @@ test('the spec defaults are what this implementation uses', () => { assert.deepEqual(DEFAULT_PARAMS, vectors.params); }); -// Three vectors do not agree with the algorithm the specification actually defines. -// They are asserted as known divergences rather than skipped, so the discrepancy is -// visible in the test output and cannot be quietly forgotten. See the README section -// "Known divergences in the aggregation vectors". -const KNOWN_DIVERGENT = { - 'agg-003': { - yields: 0.5, - why: 'Superseded by agg-003-corrected, which has identical receipts and expects 0.5. ' + - "This vector's own _explanation field works the arithmetic, catches itself mid-sentence " + - '("wait let me recalculate") and concludes 0.5, while its expected field still says 0.4.' - }, - 'agg-006': { - yields: 0.5799, - why: 'The expected 0.5 requires both issuers capped at 0.40. B\'s uncapped fraction is 0.29, ' + - 'and step 5 says min(fraction, max_issuer_weight) — a cap, not a floor. The stated intent ' + - '(4 good receipts from A cannot dominate 1 bad from B) is not what the written formula does.' - }, - 'agg-008': { - yields: 0.918, - why: 'Off by 0.0010, a hair outside the vectors\' own +/-0.001 tolerance. Consistent with the ' + - 'expected value having been computed from rounded intermediate weights.' - } -}; - for (const testCase of vectors.cases) { - const divergence = KNOWN_DIVERGENT[testCase.id]; - if (divergence) { - test(`known divergence ${testCase.id}: vector expects ${testCase.expected.score}, spec formula yields ${divergence.yields}`, () => { - const result = aggregateTrust(testCase.receipts, testCase.current_time_ms, vectors.params); - assert.ok(Math.abs(result.score - divergence.yields) <= 0.001, - `the implementation must follow the written algorithm: ${divergence.why}`); - assert.ok(Math.abs(result.score - testCase.expected.score) > 0.001, - `${testCase.id} now agrees with the vector — delete this entry from KNOWN_DIVERGENT`); - }); - continue; - } test(`conformance ${testCase.id}: ${testCase.description}`, () => { const result = aggregateTrust(testCase.receipts, testCase.current_time_ms, vectors.params); @@ -84,11 +49,10 @@ test('a bad actor cannot average away danger with good behaviour', () => { assert.ok(result.score < 0.87, `expected amplification to bite, got ${result.score}`); }); -test('capping binds, but re-normalization gives most of it back', () => { - // Worth stating plainly, because the cap does not do what its name suggests. Step 5 - // caps a dominant issuer at 0.40 and then re-normalizes across issuers — so when the - // other issuers carry little weight, the capped issuer still ends up with most of the - // vote. The cap only bites when the rest of the field is comparable in weight. +test('a capped issuer holds its cap and no more', () => { + // This is what v1.0 got wrong: it capped the dominant issuer and then re-normalized + // across everyone, handing the excess straight back. v1.1 redistributes to the + // uncapped issuers instead, so the cap means what its name says. const now = 1_700_000_000_000; const receipts = [ ...Array.from({ length: 50 }, (_, i) => ({ receipt_id: `loud${i}`, issuer_id: 'LOUD', score: 1.0, timestamp: now })), @@ -98,9 +62,14 @@ test('capping binds, but re-normalization gives most of it back', () => { const result = aggregateTrust(receipts, now); const loud = result.issuers.find((i) => i.issuer_id === 'LOUD'); assert.equal(loud.capped, true, 'the dominant issuer is capped'); - assert.ok(loud.weight > 0.8, - `re-normalization returns most of the capped weight: LOUD still holds ${loud.weight}`); - assert.ok(result.score > 0.85, 'so 50 perfect receipts from one issuer do outvote two bad ones'); + assert.ok(Math.abs(loud.weight - 0.4) < 0.001, + `LOUD must hold exactly the cap, holds ${loud.weight}`); + assert.ok(result.score < 0.55, + `50 perfect receipts from one issuer must not outvote two bad ones: ${result.score}`); + + // The weights still describe a whole. + const total = result.issuers.reduce((sum, i) => sum + i.weight, 0); + assert.ok(Math.abs(total - 1) < 0.001, `weights must sum to 1, summed to ${total}`); }); test('scores read in the words the scoring semantics define', () => { diff --git a/protocol/aggregation-spec.md b/protocol/aggregation-spec.md index 4105353..338bbc1 100644 --- a/protocol/aggregation-spec.md +++ b/protocol/aggregation-spec.md @@ -1,7 +1,8 @@ -# TTP Trust Score Aggregation Algorithm — v1.0 +# TTP Trust Score Aggregation Algorithm — v1.1 **Status:** Normative -**Part of:** Trust Transfer Protocol Specification v1.0 +**Part of:** Trust Transfer Protocol Specification v1.0 +**Algorithm version:** v1.1 (step 5 corrected — see the note in that section) --- @@ -90,22 +91,63 @@ issuer_raw_weight(i) = issuer_total_weight(i) ### Step 5 — Issuer Weight Capping -To prevent any single issuer from dominating the aggregate score, cap each issuer's contribution: +To prevent any single issuer from dominating the aggregate score, cap each issuer's +contribution and redistribute the excess to the issuers that are **not** capped. + +A cap below `1 / issuer_count` is infeasible — with two issuers, both cannot sit under +0.40 — so the cap that is actually applied is: + +``` +effective_cap = max(max_issuer_weight, 1 / issuer_count) +``` + +Then water-fill: cap whichever issuers exceed it, share their excess among the uncapped +issuers in proportion to the weight they already hold, and repeat until no issuer exceeds +the cap. ``` -total_raw_weight = sum(issuer_raw_weight(i) for all issuers i) +weight(i) = issuer_raw_weight(i) / total_raw_weight # initial fractions, sum to 1 + +repeat (at most issuer_count times): + over = { i : not capped(i) and weight(i) > effective_cap } + if over is empty: stop -for each issuer i: - uncapped_fraction(i) = issuer_raw_weight(i) / total_raw_weight - capped_fraction(i) = min(uncapped_fraction(i), max_issuer_weight) + excess = 0 + for each i in over: + excess += weight(i) - effective_cap + weight(i) = effective_cap + capped(i) = true -# Re-normalize capped fractions to sum to 1.0 -normalization_factor = sum(capped_fraction(i) for all issuers i) + free = { i : not capped(i) } + if free is empty: stop + free_total = sum(weight(i) for i in free) -for each issuer i: - normalized_weight(i) = capped_fraction(i) / normalization_factor + for each i in free: + weight(i) += excess * (weight(i) / free_total) + +normalized_weight(i) = weight(i) ``` +The weights still sum to 1.0, and a capped issuer now genuinely holds `effective_cap` — +no more. + +> **Changed in v1.1.** v1.0 specified `capped_fraction(i) = min(uncapped_fraction(i), +> max_issuer_weight)` followed by re-normalizing *all* issuers back to 1.0. That +> re-normalization handed the capped issuer most of its excess straight back whenever the +> other issuers were light: with 50 receipts from one issuer and one each from two +> others, the "capped" issuer still held **87%** of the weight and the aggregate came out +> at 0.90. The cap only bit when the rest of the field was already comparable — which is +> precisely the case where a cap is not needed. +> +> Under v1.1 that same input gives the dominant issuer exactly 0.40 and an aggregate of +> 0.52. Vector `agg-006` was written to assert this intent ("4 good receipts from A cannot +> dominate 1 bad receipt from B") and did not pass under v1.0; it passes under v1.1 +> unchanged. +> +> **This changes conformance.** Implementations of v1.0 will produce different scores for +> any input where one issuer exceeds the cap while the others are light. Re-run the test +> vectors. + ### Step 6 — Final Score ``` diff --git a/protocol/test-vectors/aggregation-vectors.json b/protocol/test-vectors/aggregation-vectors.json index 5102cb1..1e6808d 100644 --- a/protocol/test-vectors/aggregation-vectors.json +++ b/protocol/test-vectors/aggregation-vectors.json @@ -1,8 +1,8 @@ { - "_comment": "Aggregation algorithm test vectors. All implementations MUST produce scores within ±0.001 of expected_score.", + "_comment": "Aggregation algorithm test vectors for v1.1. All implementations MUST produce scores within \u00b10.001 of expected_score. Changed in v1.1: step 5 redistributes capped weight to uncapped issuers rather than re-normalizing across all of them; agg-003 was superseded by its own corrected variant and removed; agg-008 was recomputed from unrounded weights.", "params": { "receipt_window_s": 300, - "max_issuer_weight": 0.40, + "max_issuer_weight": 0.4, "negative_weight_multiplier": 1.5, "decay_half_life_s": 120 }, @@ -12,7 +12,12 @@ "description": "Single issuer, single perfect receipt, age 0", "current_time_ms": 1700000000000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 1.0, "timestamp": 1700000000000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + } ], "expected": { "score": 1.0, @@ -25,7 +30,12 @@ "description": "Single issuer, single zero receipt, age 0", "current_time_ms": 1700000000000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 0.0, "timestamp": 1700000000000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 0.0, + "timestamp": 1700000000000 + } ], "expected": { "score": 0.0, @@ -35,26 +45,21 @@ }, { "id": "agg-003", - "description": "Two issuers, equal fresh receipts, divergent scores. Negative amplification shifts result toward safety.", + "description": "Two issuers, equal fresh receipts, divergent scores. B is negative (amplified). Final score 0.5.", "current_time_ms": 1700000000000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 1.0, "timestamp": 1700000000000 }, - { "receipt_id": "r2", "issuer_id": "B", "score": 0.0, "timestamp": 1700000000000 } - ], - "expected": { - "score": 0.4, - "contributing_receipts": 2, - "contributing_issuers": 2, - "_explanation": "A: weight=1.0, score=1.0. B: score=0.0<0.5 → weight=1.5. After issuer cap (both under 40%): A_raw=1.0, B_raw=1.5, total=2.5. A_capped=0.4, B_capped=0.4, norm_factor=0.8. A_norm=0.5, B_norm=0.5. Final=1.0*0.5+0.0*0.5=0.5... wait let me recalculate. A_fraction=1.0/2.5=0.4, B_fraction=1.5/2.5=0.6. B capped at 0.4. norm_factor=0.4+0.4=0.8. A_norm=0.4/0.8=0.5, B_norm=0.4/0.8=0.5. score=1.0*0.5+0.0*0.5=0.5" - } - }, - { - "id": "agg-003-corrected", - "description": "Two issuers, divergent scores. B is negative (amplified). Final score ~0.5.", - "current_time_ms": 1700000000000, - "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 1.0, "timestamp": 1700000000000 }, - { "receipt_id": "r2", "issuer_id": "B", "score": 0.0, "timestamp": 1700000000000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "r2", + "issuer_id": "B", + "score": 0.0, + "timestamp": 1700000000000 + } ], "expected": { "score": 0.5, @@ -67,14 +72,24 @@ "description": "Decay: two receipts from same issuer. Recent=1.0, old (1 half-life)=0.0. Old receipt is negative, amplified.", "current_time_ms": 1700000120000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 1.0, "timestamp": 1700000120000 }, - { "receipt_id": "r2", "issuer_id": "A", "score": 0.0, "timestamp": 1700000000000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000120000 + }, + { + "receipt_id": "r2", + "issuer_id": "A", + "score": 0.0, + "timestamp": 1700000000000 + } ], "expected": { "score": 0.571, "contributing_receipts": 2, "contributing_issuers": 1, - "_explanation": "recent: decay=1.0, weight=1.0 (positive). old (age=120s=1 half-life): decay=0.5, score=0.0<0.5 → weight=0.5*1.5=0.75. issuer_score(A)=(1.0*1.0+0.0*0.75)/(1.0+0.75)=1.0/1.75=0.5714" + "_explanation": "recent: decay=1.0, weight=1.0 (positive). old (age=120s=1 half-life): decay=0.5, score=0.0<0.5 \u2192 weight=0.5*1.5=0.75. issuer_score(A)=(1.0*1.0+0.0*0.75)/(1.0+0.75)=1.0/1.75=0.5714" } }, { @@ -82,8 +97,18 @@ "description": "Receipts outside window are excluded", "current_time_ms": 1700000400000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 1.0, "timestamp": 1700000400000 }, - { "receipt_id": "r2", "issuer_id": "B", "score": 0.0, "timestamp": 1699999900000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000400000 + }, + { + "receipt_id": "r2", + "issuer_id": "B", + "score": 0.0, + "timestamp": 1699999900000 + } ], "expected": { "score": 1.0, @@ -97,25 +122,55 @@ "description": "Single issuer with many receipts cannot exceed max_issuer_weight when other issuers are present", "current_time_ms": 1700000000000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 1.0, "timestamp": 1700000000000 }, - { "receipt_id": "r2", "issuer_id": "A", "score": 1.0, "timestamp": 1699999990000 }, - { "receipt_id": "r3", "issuer_id": "A", "score": 1.0, "timestamp": 1699999980000 }, - { "receipt_id": "r4", "issuer_id": "A", "score": 1.0, "timestamp": 1699999970000 }, - { "receipt_id": "r5", "issuer_id": "B", "score": 0.0, "timestamp": 1700000000000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "r2", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1699999990000 + }, + { + "receipt_id": "r3", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1699999980000 + }, + { + "receipt_id": "r4", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1699999970000 + }, + { + "receipt_id": "r5", + "issuer_id": "B", + "score": 0.0, + "timestamp": 1700000000000 + } ], "expected": { "score": 0.5, "contributing_receipts": 5, "contributing_issuers": 2, - "_explanation": "A has 4 receipts, B has 1. A's raw weight >> B's raw weight. After capping A at 40% and B at 40% (B's negative amplifies to approach cap), normalized weights are 50/50. score = 1.0*0.5 + 0.0*0.5 = 0.5. This demonstrates that 4 good receipts from A cannot dominate 1 bad receipt from B." + "_explanation": "Under v1.1 the cap redistributes to uncapped issuers, and with two issuers the effective cap is max(0.40, 1/2) = 0.50, so the weights are 50/50 and the score is 0.5. This vector asserts the intent v1.0 could not deliver: 4 good receipts from A cannot dominate 1 bad receipt from B." } }, { "id": "agg-007", - "description": "No receipts in window — must return INSUFFICIENT_TRUST_DATA", + "description": "No receipts in window \u2014 must return INSUFFICIENT_TRUST_DATA", "current_time_ms": 1700001000000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 0.9, "timestamp": 1699999900000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 0.9, + "timestamp": 1699999900000 + } ], "expected": { "error": "INSUFFICIENT_TRUST_DATA", @@ -124,17 +179,414 @@ }, { "id": "agg-008", - "description": "Three issuers, all excellent — high composite score", + "description": "Three issuers, all excellent \u2014 high composite score", "current_time_ms": 1700000000000, "receipts": [ - { "receipt_id": "r1", "issuer_id": "A", "score": 0.95, "timestamp": 1700000000000 }, - { "receipt_id": "r2", "issuer_id": "B", "score": 0.92, "timestamp": 1699999990000 }, - { "receipt_id": "r3", "issuer_id": "C", "score": 0.88, "timestamp": 1699999980000 } + { + "receipt_id": "r1", + "issuer_id": "A", + "score": 0.95, + "timestamp": 1700000000000 + }, + { + "receipt_id": "r2", + "issuer_id": "B", + "score": 0.92, + "timestamp": 1699999990000 + }, + { + "receipt_id": "r3", + "issuer_id": "C", + "score": 0.88, + "timestamp": 1699999980000 + } ], "expected": { - "score": 0.917, + "score": 0.918, "contributing_receipts": 3, - "contributing_issuers": 3 + "contributing_issuers": 3, + "_note": "Corrected in v1.1: recomputed from unrounded weights. Was 0.917." + } + }, + { + "id": "agg-009", + "description": "v1.1 capping: one issuer with 50 receipts cannot outvote two issuers with one bad receipt each", + "current_time_ms": 1700000000000, + "receipts": [ + { + "receipt_id": "loud0", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud2", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud3", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud4", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud5", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud6", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud7", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud8", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud9", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud10", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud11", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud12", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud13", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud14", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud15", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud16", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud17", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud18", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud19", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud20", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud21", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud22", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud23", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud24", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud25", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud26", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud27", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud28", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud29", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud30", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud31", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud32", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud33", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud34", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud35", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud36", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud37", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud38", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud39", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud40", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud41", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud42", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud43", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud44", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud45", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud46", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud47", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud48", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "loud49", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "b1", + "issuer_id": "B", + "score": 0.2, + "timestamp": 1700000000000 + }, + { + "receipt_id": "c1", + "issuer_id": "C", + "score": 0.2, + "timestamp": 1700000000000 + } + ], + "expected": { + "score": 0.52, + "contributing_receipts": 52, + "contributing_issuers": 3, + "_explanation": "A is capped at max(0.40, 1/3) = 0.40 and its excess goes to B and C, which hold 0.30 each. score = 1.0*0.40 + 0.2*0.30 + 0.2*0.30 = 0.52. Under v1.0 re-normalization returned the excess to A, which kept 87% of the weight and produced 0.90." + } + }, + { + "id": "agg-010", + "description": "A cap below 1/issuer_count is infeasible: with two issuers the effective cap is 0.5", + "current_time_ms": 1700000000000, + "receipts": [ + { + "receipt_id": "a1", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "a2", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "a3", + "issuer_id": "A", + "score": 1.0, + "timestamp": 1700000000000 + }, + { + "receipt_id": "b1", + "issuer_id": "B", + "score": 0.8, + "timestamp": 1700000000000 + } + ], + "expected": { + "score": 0.9, + "contributing_receipts": 4, + "contributing_issuers": 2, + "_explanation": "effective_cap = max(0.40, 1/2) = 0.50. A is capped to 0.50 and B takes the excess, so both hold 0.50. score = 1.0*0.5 + 0.8*0.5 = 0.9." + } + }, + { + "id": "agg-011", + "description": "A single issuer keeps full weight: there is nobody to redistribute to", + "current_time_ms": 1700000000000, + "receipts": [ + { + "receipt_id": "a1", + "issuer_id": "A", + "score": 0.8, + "timestamp": 1700000000000 + } + ], + "expected": { + "score": 0.8, + "contributing_receipts": 1, + "contributing_issuers": 1, + "_explanation": "effective_cap = max(0.40, 1/1) = 1.0." } } ]