Repository navigation
[Fellowship Challenge] trust: reputation.capped.v1, a per-observer influence cap - #290
Conversation
reputation.v1 adds +1 or -1 per receipt with no cap and no notion of who
is reporting, so influence scales with how many receipts an agent writes.
An agent that has never traded can file bad receipts in a loop and drive a
counterparty's score arbitrarily low.
This commit is deliberately red. The invariant under test is that any one
observer moves a score by at most 1:
assert api.reputation("seller-a") >= -1
AssertionError: assert -5 >= -1
The second test is the reason the first one is needed. It passes today:
reputation_consistent replays the reference +1/-1 formula over the same
slander trace and reports "passed", because every step of the arithmetic
is internally consistent. That verdict is true and beside the point. A
check that reproduces a formula cannot object to the formula, so the gap
is invisible to the evaluator as it stands.
Red until reputation.capped.v1 lands.
Registered like the existing spoofer, so a scenario can name it under role: slanderer. It publishes a "review" capability, waits until the honest trade has settled, then files a configured number of bad trade.outcome receipts about a configured target, one per interval. The attack is volume from a single voice, not forgery. Every receipt is a genuine attributed record, signed by the slanderer, about a subject that is not itself; data_facts already refuses a self-report, so nothing here sidesteps the evidence rule. What makes it work is that the default trust layer counts each report as a full vote. Config: target, reports (default 5), start_after (default 3.0), interval (default 0.5). The default start_after is after the marketplace trade settles at logical t=1.90, so the slander lands on an established score rather than racing it. Still red: the role can now stage the attack but nothing bounds it yet.
Turns the previous two commits green.
score(subject) = sum over distinct observers of
weight(observer) * clamp(net(observer, subject), -1, +1)
net = goods minus bads that observer reported about subject
weight = 1 if the observer was party to a settled trade, else 0
One observer is worth at most one point no matter how many receipts it
files, and an observer with no trading history of its own is worth
nothing. Both halves are load-bearing: the cap alone still lets a crowd of
fresh identities vote, the weight alone still lets one established agent
shout. Distinct observers still accumulate, so the cap limits a voice, not
the score.
Why the weight reads payment_settled. An observer's trade history has to
be evidence a replaying third party can see, and it must not be
self-certifiable. Receipts about the observer were the other candidate,
but in this town nobody ever attests about a buyer, so that definition
silences the honest buyer along with the slanderer and every score stays
0. A settled payment names both parties, which makes an honest buyer count
and a pure reviewer not count. This layer only reads those events. It
never writes to payments and does not change how money moves.
A discarded report still emits reputation_updated, carrying delta 0 plus
observer_weight and a reason. An "updated" event that changed nothing is
slightly awkward, but dropping it would hide an attributed report from the
trace, and a report that was made should be recorded whether or not it
counted.
reputation.v1 is untouched and remains the default for every existing
scenario. The invariant test is repointed at the bounded layer rather than
deleted, and reputation.v1's -5 stays pinned as a parametrized case, so
the gap this addresses cannot silently close.
Note that weight is a property of current state, not of report time: an
observer that settles a trade later gains weight over reports it already
filed. That follows from the formula being a pure function of the trace so
far, which is what makes it replayable.
reputation_consistent cannot express the bounded rule, and this is the
tradeoff behind adding a second check rather than widening the first.
It recomputes an unbounded running sum and requires detail["score"] to
equal it exactly, with delta in {+1, -1}. A clamped score fails it by
construction, and so does the delta of 0 the bounded layer emits for a
discarded report. The options were to relax that check so both formulas
pass it, or leave it alone and add a second one.
Relaxing it would weaken a check that currently pins nineteen regressions
in tests/test_lab_reputation.py, in order to accommodate a formula those
scenarios do not use, and it would silently re-verify existing marketplace
bundles under looser rules. So the reference check keeps its exact meaning
and this one states the bounded invariant separately. The cost is two
checks to maintain, and a bundle is judged only by whichever one its
scenario selects: neither validates the other's formula. There is a test
for that, since a bounded trace fails reputation_consistent by design.
This is a pure addition (232 insertions, 0 deletions). No existing
validator's semantics change, and reputation_consistent is not a standing
check, so nothing that runs today calls the new code.
reputation_capped checks two separate claims. First the recorded
movement: the deltas one observer actually caused for one subject must sum
within [-1, +1], and to exactly 0 when no settled payment naming that
observer precedes its reports. Second the recorded totals: the score on
each update must equal the capped formula recomputed over every observer
seen so far. The first is the invariant, the second is integrity; a layer
could satisfy either alone.
The invariant runs as a pre-pass over the whole trace rather than failing
on the first violation, so the note names the full movement. Against
reputation.v1 it reports "moved 'seller-honest' by -5; one observer is
capped at +/-1" rather than stopping at the first -1, which is the
difference between a report that names the attack and one that hints at it.
Both checks read the same receipt attribution as the reference: a matching
prior receipt, same run, same observer and subject, no self-report, claim
trade.outcome, value equal to the outcome, each receipt used once. Like the
reference, it tests recorded claims. It does not establish that a reporter
told the truth, held authority, or that a settled payment reflects a real
trade.
capped_influence: an honest seller, a buyer that actually trades with it, and
a slanderer that files five bad receipts about it and never trades at all.
Under reputation.capped.v1 the slanderer has no weight, its five reports
move nothing, the buyer's single rating still counts, and the trade closes.
Verdict PASSED.
capped_influence_uncapped_control: byte-for-byte the same agents, same seed,
with trust left at reputation.v1. Verdict FAILED, on purpose:
influence_capped Failed
observer 'slanderer' moved 'seller-honest' by -5;
one observer is capped at +/-1
honest_signal_survives Failed
the slandered seller must keep a positive score from the buyer it
actually traded with
Both bundles verify, so the failing verdict replays exactly. The control
follows capability_spoofing_weak_auth: it selects the same validator and
changes one layer, so the only variable is the rule under test. A test
asserts the two specs differ in exactly the trust layer and nothing else.
The validator reports four separate stages. slander_filed is there so a
pass cannot come from an attack that never ran, and honest_trade_completed
so a pass cannot come from a town that stopped trading. honest_signal_
survives is the one that keeps the cap honest in the other direction:
silencing abuse must not silence real feedback, which a trust layer that
simply returned 0 for everyone would otherwise satisfy.
Also extends the two test manifests that assert exact sets, since they
would otherwise reject any new bundled scenario: ALL_SCENARIOS and
FAILING_SCENARIOS in test_sim.py, and the NATIVE_SCENARIO_STAGES matrix in
test_lab_coverage.py. Both are additive.
nandatown schemas produces no diff; no shared record changed.
README: two rows in the Lab scenarios table, and reputation.capped.v1 named on the trust row of the twelve-layers table. Both tables list every bundled scenario and each layer's options, so they were stale. Cuts the design rationale out of the source and leaves the rules. The trust_capped module docstring went from 54 lines to 15, against a 6-line norm for the other layer modules; reputation_capped and the slanderer role lose their prose in the same way. The reasoning is in the commit messages, where it does not have to be reread on every visit. trust_capped.update also drops a redundant delta local in favour of comparing the scores directly. Verified behaviour-preserving: the capped_influence trace is identical event for event, run id aside.
|
There's a payment transition missing from the tests. Alice rates a seller before she has any settled payment, so the score is 0. Alice then pays Bob one cent in the Lab. The runtime now counts her earlier report and returns -1, without another reputation update. When Bob rates the seller good, the runtime records score 0, but Your PR explicitly chooses current-state eligibility, which is a reasonable policy to test. The implementation and verifier need to agree on it. Please add that sequence as a regression test and make payment-time reweighting, recorded deltas and replay consistent. If you choose report-time eligibility instead, update the implementation and docs together. I'd keep this focused on that lifecycle; it doesn't need to solve Sybils in this PR. |
RED BY DESIGN. Pins the divergence raised in review of projnanda#290. Eligibility under reputation.capped.v1 is evaluated at current state, so a settled payment makes an observer's earlier reports start counting. That policy is intended. What is broken is that the transition is invisible: nothing records the moment, so the runtime and the verifier drift apart. The sequence: alice rates a seller bad with no settled payment (score 0), alice settles one cent with bob, bob rates the seller good. Two failures, both the reported ones: alice settles one cent: newest recorded score +0 but the plugin holds -1; the move is invisible in the trace recorded score +0 does not match the capped formula's +1 The second is the maintainer's wording exactly. They are the same defect seen from both ends. score() polls engine.events through traded(), so alice's weight flips silently and her earlier report starts counting with no event to show it. reputation_capped only refreshes an observer's weight while processing that observer's own update, so alice's weight stays 0 in replay forever, because she never reports again. The runtime counts her, the verifier does not, and neither can be derived from the trace. A third test pins the invariant that makes the defect structural: recorded deltas per (observer, subject) must sum to the recorded score. Today alice's deltas sum to 0 while she contributes -1. Asserts three views at every step, since any one of them alone hides this: the score the plugin holds, the score in the newest recorded event, and the score the validator reconstructs from events.jsonl alone.
Fixes the lifecycle pinned by the previous commit, keeping current-state eligibility. The policy was never the bug; the bug was that the moment a report started counting left no trace, so the runtime and the verifier each guessed and guessed differently. Steps 2, 3 and 5 of the plan land together on purpose. Once the layer emits a new event kind the old validator ignores it and still mis-replays, so splitting them would leave a second red commit. Layer. score() is now pure over reports and weights and reads no events; it used to scan engine.events through traded(), which is what let a weight flip silently between two reads. Weight is held in a dict and flips in on_event when a payment_settled first names the observer. If that observer already has reports on file, the layer emits one reputation_reweighted per subject it had reported on, carrying the 0 to 1 transition, the score either side, the delta, the formula, the reason observer_weight_gained, and the id of the payment that caused it. Emission is at settle time, not lazily at the next read. The property this buys: for any (observer, subject) the recorded deltas now sum to the recorded score. Before, alice's deltas summed to 0 while she contributed -1. Engine. There was no way for a layer to observe another layer's events, so this adds one: Engine collects on_event from any layer that defines it and calls them after appending. Six lines. Every other layer is untouched and never called, so _listeners is empty for all six existing scenarios and for the control, and their traces are byte-identical. Both bundled profile fingerprints are unchanged. A listener may emit, which nests one level and must not react to its own kinds; the docstring says so. Validator. reputation_capped replays in trace order and no longer infers a weight per update from _settled_parties. That inference was the other half of the defect: it only refreshed an observer's weight while processing that observer's own update, so alice's weight stayed 0 forever once she stopped reporting. Weight now flips where the trace says it did, and the replay checks each transition against the formula. Enfranchisements are owed in emission order, so a payment naming two parties keeps their sequence, and a reputation_updated or payment_settled encountered while one is still owed fails naming the missing event rather than reporting a downstream arithmetic mismatch. Both claims are intact. The cap and the zero-weight rule now read deltas from reports and reweights alike, and judge an observer against whether any settled payment ever names it, which is what current-state eligibility means. The control still fails on the cap, by -5, for the original reason: reputation.v1 is unbounded. It emits no reweight events, and neither does the healthy scenario, since in both the agents settle before they report. Docs. The module docstring states the policy, the retroactive effect, that the moment is recorded so replay is exact, and the trade-off: a late payment changes what a past report contributes, where report-time eligibility would freeze weight and be cheaper to verify but would let an agent file reports before paying and never earn the weight. Tests. Eleven more, covering the recorded transition and its payment correlation, an observer that settles before reporting and is owed nothing, the cap holding across enfranchisement, and seven tampered traces. Three of those are caught by checks already present rather than new ones: duplicating a reweight trips the cap at -2, and deleting the payment trips the zero-weight rule. Full suite 1796 passed. nandatown schemas produces no diff; event kinds are free-form strings in TownEvent.
|
fixed in The two sides disagreed in opposite directions: Report-time eligibility would be cheaper to verify but would let an agent file reports before paying and never earn the weight; the trade-off is in the docstring. One thing outside the trust layer: the engine had no way for a layer to see another's events, so I added one — six lines, inert unless a layer defines |
|
Thanks, this fixes it. The reweight event is a good way to make the transition visible, and the new test checks the plugin, the newest event and the replay at each step. On 2f890b6 the full suite passes, both scenarios still give the expected pass/fail and verify, and the late-payment sequence from my comment now replays to the same score as the runtime. The engine hook is the one thing a maintainer should look at, since this is now a change to core and not just the trust layer. It's small and I think it's the right shape. If it stays, I'd let a layer subscribe to the event kinds it needs (here just |
Review follow-up on projnanda#290. A layer now names the kinds it wants in subscribes_to and the engine dispatches only those, replacing a call to every layer defining on_event for every event. reputation.capped.v1 subscribes to payment_settled alone, so it is called once per run rather than once per event: 1 call across 46 events in capped_influence, and the six existing scenarios plus the control build no subscribers at all. The no-loop rule is now enforced rather than documented. Re-entering the same layer for the same kind raises SubscriptionLoop instead of recursing, and nothing subscribes to reputation_reweighted, so emitting from inside a subscriber stays legal while a cycle cannot form. No behaviour change: all four scenario traces are unchanged and deterministic, both bundled profile fingerprints are identical, and both bundles still verify. The control still fails on the cap at -5. Five tests: the declared subscription, no subscribers in scenarios without the layer, only the subscribed kind reaching on_event, the loop guard firing, and a subscriber emitting another kind without tripping it.
|
Done in 9dc2113. No behavior change. All four scenario traces are identical and deterministic; both profile fingerprints are unchanged, both bundles verify, and the control still fails on the cap at −5. Five tests cover the declared subscription, the empty subscriber set in scenarios without the layer, only the subscribed kind reaching on_event, the guard firing, and a subscriber emitting a different kind without tripping it. Thanks for the suggestion—it's a better shape than what I had. |
|
Checked 9dc2113. The explicit subscription and re-entry guard cover what I was getting at. The full suite passes with 1,801 tests, the late-payment case now replays correctly, and the positive/control bundles both verify. Nothing further from me on this change. A maintainer should still sign off on the core engine hook before merging. |
|
The earlier fixes still hold. I ran the full suite again on 9dc2113 (1,801 passed), then checked a few more lifecycle cases. Three things I'd close before merging:
The event subscription approach still makes sense to me. These are about carrying its consequences through the evaluator and keeping the existing plugin lifecycle working, not changing the policy. |
Three review findings on 9dc2113, all introduced by this PR and all reproduced before fixing. honest_signal_survives read only reputation_updated, so a reweight that moved a score afterwards was invisible to it. Paying the slanderer one cent at t=10 in capped_influence takes seller-honest from +1 to 0 and the stage still passed, with the run passing overall. It now reads both kinds and fails, which is what the stage exists for. influence_capped was right throughout: the cap held, the signal did not. reputation_capped resolved a receipt and skipped the update when it was absent, so one missing receipt desynchronised every later score and came back as a cap violation or an unowed reweight. The report is now applied before its receipt is resolved, leaving only attribution unproven, which is missing evidence. Both reported sequences return not_enough_evidence. reputation_consistent updates before it checks, which is why it never had this. A test covers the other direction: a real arithmetic error still fails with a receipt missing, so incomplete evidence is not cover. Engine dispatch state was assigned after the layers were built, so a plugin calling engine.emit() from its own constructor hit AttributeError on _subscribers where it worked on main. Both fields are set before any layer is constructed. Six tests. No behaviour change to either scenario: verdicts, bundle verification and both profile fingerprints are unchanged, the control still fails on the cap at -5, and nandatown schemas produces no diff. Full suite 1812 passed.
|
All three reproduced and fixed in fbc0cca.
Six tests added. No behaviour change to either scenario: verdicts, bundle verification and both profile fingerprints unchanged, the control still fails on the cap at −5, and Thanks for digging into the lifecycle cases—all three were mine, and the second one in particular was a check making a claim it hadn't earned. Happy to answer anything on the engine hook for whoever picks up the core sign-off. |
|
Thanks, Adarsh. I reran the three cases on fbc0cca. The late payment now correctly fails the honest-signal stage, missing receipts no longer break the arithmetic replay, and emitting an event from a plugin constructor works again. The full Linux/Python 3.12 suite passed: 1,806 passed, six skipped. Builds, clean-wheel checks, and the normal/control runs also passed with the expected results. I don't have any remaining changes to request on this PR. Looks ready from my review. |
Thanks a lot @JamesCarnley for flagging everything |
The slandered seller is ranked last and never receives a quote request: an availability attack with no defence and no detection.
This Is Not a Re-submission of Earlier Trust Work
No trust plugin other than
reputation.v1exists on currentmain(git ls-tree origin/mainshows onlylayers/trust.py).The earlier submissions — EigenTrust (#2, #3, #6), TrustGuard (#27, #218), SybilResistantTrust (#139), WeightedTrust (#185), receipt-corroboration (#26), attested-peering (#149), and
bonded_trust(#53) — live inpackages/nest-plugins-reference/onarchive/legacy, targetscore_average, and areasync. None of that package tree or that reference plugin survives the rebuild.The closest by mechanism is #53,
bonded_trust, and it makes the opposite commitment on the same axis. Its docstring states that:It bounds influence by spent bond, independent of identity count.
This plugin bounds influence per observer, independent of standing: no reporter can move a score by more than
1, however much it has traded.bonded_trustis a float bond-weighted mean over an injectedStakeLedger; this is an integer sum of per-observer clamped contributions over an event the town already records.One decides who gets to dominate; this one decides that nobody does.
They compose — an admission price and an influence bound are different questions — but they are not substitutes.
The Layer
trust, and onlytrust.New plugin:
reputation.capped.v1(src/nandatown/layers/trust_capped.py), registered alongsidereputation.v1, which is byte-identical toorigin/main.One observer can move a score by at most
1, regardless of how many receipts it files.An observer with no settled trade of its own moves nothing.
Distinct observers still accumulate, so the cap limits a voice, not the score.
A discarded report is still emitted with:
{ "delta": 0, "reason": "observer_cap_reached" }or:
{ "delta": 0, "reason": "observer_has_no_settled_trade" }The suppression is therefore present in the trace rather than absent from it.
The Fault Is the Participant, Not a
FaultRuleNeither scenario declares a
faults:key, so the report prints:That is deliberate.
The fault is a new
slandererrole (src/nandatown/sim/agents.py) that:seller-honestat0.5logical-second intervals.FaultRuleoffers onlydrop,duplicate,delay, anddrop_rate(sim/scenario.py:31), which perturb delivery rather than introduce a hostile participant.The precedent is the existing
spooferrole.The Invariant
Under the slander burst,
seller-honestkeeps a positive score from the buyer's own rating, and the trade completes.Scenarios
The scenarios use identical agents and the same seed, differing in exactly one layer — asserted by a test, not by inspection.
capped_influenceResult: PASSED
capped_influence_uncapped_controlOnly the trust layer differs:
The same validator was used.
Result: FAILED at
influence_cappedwith:This is the predicted failure, followed by failure at
honest_signal_survives.Both bundles pass
nandatown verify, so the failing verdict replays.Evaluator:
lab-0.2.6.Profile fingerprints are stable across runs and are the reproducible identifier. Per-bundle
bundle_fingerprintvalues are not stable because each run mints a new run ID.Why a Companion Validator
reputation_consistentreplays the unbounded sum and requiresdetail["score"]to equal it exactly, withdeltain{+1, -1}.It therefore rejects a capped score and a zero delta by construction.
It also reports
passedon the unbounded slander trace, so it cannot express this invariant without changing what it means for every existing bundle.reputation_cappedis therefore separate and additive (validators.py,+232/-0).It checks two claims:
[-1, +1], and sum to0when there is no prior settled payment naming that observer.Both are recoverable from
events.jsonlalone.A test asserts that the two checks stay independent: the capped trace fails
reputation_consistentby design.Stages
The evaluator stages are:
slander_filed— a pass cannot come from an attack that never ran.influence_capped— the core invariant.honest_signal_survives— a layer returning0for everyone would satisfy the cap but fail this.honest_trade_completedWhat the Evidence Proves — and What It Does Not
It proves
It does not prove
The cap binds one identity, not one actor.
NSybils with settled payments can each still contribute1.Weight is a boolean on trade history, not a reputation weighting. It is therefore not recursive and cannot be gamed by score farming. Equally, it does not distinguish a large counterparty from a trivial one.
Weight is a property of current state, not report time. An observer that settles a trade later gains weight over reports it already filed. This follows from the formula being a pure function of the trace so far.
Tests
tests/test_lab_capped_influence.pycontains 24 tests.The first commit (
d076983) pins the unbounded behaviour and is deliberately red, as is48aeeb0.760a4e9turns them green and repoints the invariant assertion at the capped layer, while keepingreputation.v1's-5as a parametrized case so the gap cannot silently close.Later tests cover:
nandatown schemasproduces no diff.Next
The cap binds identities, so the next step is making identities costly to mint — a registry and identity question requiring a second layer, and one where #53's bond mechanism could compose with this.
Weighting a report by the size of the reporter's settled trade would require
paymentsto expose per-counterparty volume to the trust layer, which it does not today.