Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
44 changes: 25 additions & 19 deletions packages/pctr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 25 additions & 14 deletions packages/pctr/src/aggregate.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// 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.
//
// The two properties that matter, both deliberate:
// - 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,
Expand Down Expand Up @@ -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
}))
};
}
Expand Down
55 changes: 12 additions & 43 deletions packages/pctr/tests/aggregation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 })),
Expand All @@ -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', () => {
Expand Down
64 changes: 53 additions & 11 deletions protocol/aggregation-spec.md
Original file line number Diff line number Diff line change
@@ -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)

---

Expand Down Expand Up @@ -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

```
Expand Down
Loading
Loading