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." } } ]