Skip to content

[Fellowship Challenge] trust: reputation.capped.v1, a per-observer influence cap - #290

Merged
JamesCarnley merged 10 commits into
projnanda:mainfrom
adarsh-jha-dev:challenge/adarsh-jha-dev-capped-influence
Oct 12, 2026
Merged

JamesCarnley merged 10 commits into
projnanda:mainfrom
adarsh-jha-dev:challenge/adarsh-jha-dev-capped-influence

Conversation

@adarsh-jha-dev

@adarsh-jha-dev adarsh-jha-dev commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor
# Submission for the Hexaware × Project NANDA Fellowship Technical Challenge

## The Gap

`trust/reputation.v1` sums `+1/-1` per receipt with no bound on how much any single observer can move a score. `reputation_consistent` is explicit that it checks the recorded claim, not whether the reporter told the truth or held authority.

One agent filing **N distinct bad receipts** against an honest seller drives that seller's score to `-N`, and every check still passes because each individual update follows the reference formula exactly. Volume from one voice, nothing forged.

This is not hypothetical. `registry_layer.py:51` sorts discovery by `-trust.score(name)`, and the reference buyer queries only the top two (`agents.py:105`). Five bad receipts from one agent that never trades, against three honest sellers:

```text
reputation.v1         s-honest -5   top2 ['s-rival-a','s-rival-b']   reached: NO
reputation.capped.v1  s-honest  0   top2 ['s-honest','s-rival-a']    reached: yes

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.v1 exists on current main (git ls-tree origin/main shows only layers/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 in packages/nest-plugins-reference/ on archive/legacy, target score_average, and are async. 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:

a reporter holding a bond majority can dominate the sign of a score — that is the intended "most skin in the game, most say"

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_trust is a float bond-weighted mean over an injected StakeLedger; 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 only trust.

New plugin: reputation.capped.v1 (src/nandatown/layers/trust_capped.py), registered alongside reputation.v1, which is byte-identical to origin/main.

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 a settled payment names the observer, else 0

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 FaultRule

Neither scenario declares a faults: key, so the report prints:

Faults: none

That is deliberate.

The fault is a new slanderer role (src/nandatown/sim/agents.py) that:

  • registers,
  • never trades, and
  • files five attributed bad receipts about seller-honest at 0.5 logical-second intervals.

FaultRule offers only drop, duplicate, delay, and drop_rate (sim/scenario.py:31), which perturb delivery rather than introduce a hostile participant.

The precedent is the existing spoofer role.

The Invariant

No single observer may move a subject's score by more than +/-1, and an observer with no settled payment naming it may not move a score at all.

Under the slander burst, seller-honest keeps 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_influence

trust: reputation.capped.v1

Result: PASSED

profile_fingerprint:
sha256:1331aa266066130c74daeef43758cdc827ad8baa0df8fe188564148c1d05122

capped_influence_uncapped_control

Only the trust layer differs:

trust: reputation.v1

The same validator was used.

Result: FAILED at influence_capped with:

observer 'slanderer' moved 'seller-honest' by -5;
one observer is capped at +/-1

This is the predicted failure, followed by failure at honest_signal_survives.

profile_fingerprint:
sha256:9778f42c292635c0425ff909ec87dc7bca8fed9298d2cb7bde9044d5fa7a2e8d

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_fingerprint values are not stable because each run mints a new run ID.

Why a Companion Validator

reputation_consistent replays the unbounded sum and requires detail["score"] to equal it exactly, with delta in {+1, -1}.

It therefore rejects a capped score and a zero delta by construction.

It also reports passed on the unbounded slander trace, so it cannot express this invariant without changing what it means for every existing bundle.

reputation_capped is therefore separate and additive (validators.py, +232/-0).

It checks two claims:

  1. The deltas one observer caused for one subject sum within [-1, +1], and sum to 0 when there is no prior settled payment naming that observer.
  2. Each recorded score equals the capped formula recomputed over the observers seen so far.

Both are recoverable from events.jsonl alone.

A test asserts that the two checks stay independent: the capped trace fails reputation_consistent by 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 returning 0 for everyone would satisfy the cap but fail this.
  • honest_trade_completed
  • The two standing checks.

What the Evidence Proves — and What It Does Not

It proves

  • The recorded claims are internally consistent.
  • No single observer's reports moved a score past the cap.
  • An observer without a settled payment naming the subject cannot influence the score.
  • Distinct observers can still accumulate influence.

It does not prove

  • That any reporter told the truth.
  • That a settled payment reflects a real trade.
  • That reports were independent.

The cap binds one identity, not one actor. N Sybils with settled payments can each still contribute 1.

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.py contains 24 tests.

The first commit (d076983) pins the unbounded behaviour and is deliberately red, as is 48aeeb0.

760a4e9 turns them green and repoints the invariant assertion at the capped layer, while keeping reputation.v1's -5 as a parametrized case so the gap cannot silently close.

Later tests cover:

  • the cap,
  • the zero-weight rule,
  • accumulation across distinct observers,
  • the control's failure mode, and
  • eleven tampered-trace cases.

nandatown schemas produces 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 payments to expose per-counterparty volume to the trust layer, which it does not today.

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.
@JamesCarnley

Copy link
Copy Markdown
Collaborator

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 reputation_capped expects +1 and fails replay.

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.

@JamesCarnley
JamesCarnley self-requested a review October 6, 2026 19:27
@JamesCarnley JamesCarnley self-assigned this Oct 6, 2026
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.
@adarsh-jha-dev

Copy link
Copy Markdown
Contributor Author

fixed in 2fcac5d and 2f890b6.

The two sides disagreed in opposite directions: score() polled the event log, so the runtime counted Alice's earlier report at −1 the moment she paid, while the validator only refreshed an observer's weight on that observer's own update — and Alice never reports again, so her weight stayed 0 in replay. Neither number was recoverable from the trace. The policy was fine; the transition was invisible.
So I kept current-state and made the moment visible. Weight flips when a payment_settled first names an observer, and the layer emits a reputation_reweighted event right there with the 0→1 transition and the score either side. score() is now pure over reports and weights. The missing property now holds: per (observer, subject), recorded deltas sum to the recorded score — Alice's used to sum to 0 while she contributed −1. Your sequence is pinned as a test checking the plugin, the newest event, and the replay at every step.

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 on_event. Only this plugin does, so every other scenario's trace is byte-identical and both profile fingerprints are unchanged. Control still fails on the cap at −5. Suite green.

@JamesCarnley

Copy link
Copy Markdown
Collaborator

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 payment_settled) instead of calling every layer that defines on_event for every event. That also makes the no-loop rule something the engine can check, not just a comment.

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.
@adarsh-jha-dev

Copy link
Copy Markdown
Contributor Author

Done in 9dc2113.
A layer now names the kinds it wants—subscribes_to = ("payment_settled",) here—and the engine dispatches only those. The trust layer is called once per run instead of once per event, and every scenario that doesn't use it builds no subscribers at all.
The no-loop rule is enforced rather than commented: re-entering the same layer for the same kind raises SubscriptionLoop. Nothing subscribes to reputation_reweighted, so emitting from inside a subscriber is still fine, but a cycle can't form.

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.

@JamesCarnley

Copy link
Copy Markdown
Collaborator

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.

@JamesCarnley

Copy link
Copy Markdown
Collaborator

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:

  1. Schedule a one-cent payment from buyer-1 to slanderer at time 10 in capped_influence. The new reweight correctly takes seller-honest's score from +1 to 0, but honest_signal_survives still passes because it reads only reputation_updated. It needs to include reputation_reweighted too.

  2. With Alice's bad report, Alice paying Bob, then Bob's good report, remove only Alice's receipt_attested event from the trace. influence_capped says the reweight was not owed. Two paid observers rating good gives a similar false arithmetic failure when the first receipt is missing. These should be incomplete evidence, not proof that the cap broke. The reference reputation_consistent handles that distinction.

  3. A registered plugin that calls engine.emit() in its constructor now raises AttributeError because _subscribers is created after the layers. The same small plugin works on main. Initialize the dispatch state before constructing plugins, with a regression test.

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.
@adarsh-jha-dev

Copy link
Copy Markdown
Contributor Author

All three reproduced and fixed in fbc0cca.

  1. honest_signal_survives ignored reweights. It read only reputation_updated, so a reweight that moved a score afterwards was invisible to it. Your one-cent payment at t=10 took seller-honest from +1 to 0 and the stage still passed. It reads both kinds now and fails, which is the whole point of the stage. influence_capped was right throughout: the cap held, the signal didn't.

  2. Missing receipts were reported as broken caps. You're right that these are incomplete evidence. The cause was ordering — I resolved the receipt and skipped the update when it was absent, so one missing receipt desynchronised every later score and surfaced as a cap violation or an unowed reweight. The report is now applied before its receipt is resolved, leaving only attribution unproven. Both your sequences return not_enough_evidence. reputation_consistent updates before it checks, which is exactly why it never had this. I added the other direction too: a genuine arithmetic error still fails with a receipt missing, so this isn't cover for a real mismatch.

  3. emit() from a plugin constructor. Dispatch state was assigned after the layers were built, so _subscribers didn't exist yet. Both fields are now set before any layer is constructed, with a regression test.

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 nandatown schemas produces no diff. Full suite 1812 passed.

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.

@JamesCarnley

Copy link
Copy Markdown
Collaborator

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.

@adarsh-jha-dev

Copy link
Copy Markdown
Contributor Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants