Status: settled design — implementation pending. rev 5. rev 5 is a wording
fix, not a design change: the "byte-identical to loop-state/2" condition is
tightened from "no afterPull edge" to "fully legacy graph — every label
with no when and no timing (or a timing normalising to phase0)", because
CSU3-5's fail-closed handling of phase0 + when already diverges from
loop-state/1 with zero afterPull edges (CSU3-7, CSU6-7/8, CSU9-D4). rev 4
had closed the three rev-3 points: the phantom user-editable HARD_PITY
Parameter is removed (fixed design constant until @parameter activator support
lands); the I1′ identity separates the state term from resource movement; and
timing / when are engine-affecting, not a route / waypoints /
frames cosmetic precedent. rev 2/3 established the post-pull conditional
label (Phase 2.5); rev 1's Phase-0 when + phase: "post" activator is
abandoned.
No loop-*/N id yet. Extends loop-state/1
(SEMANTICS-S.md) / loop-state/2
(SEMANTICS-S2.md); ships under a new SEMANTICS-S*.md
revision + a loop-revision/N decision (CSU9-D4).
Prefix CSU. Sections: CSU0 why · CSU1 what today's primitives do ·
CSU2 why they can't · CSU3 proposed contract · CSU4 how a hard pity
expresses · CSU5 timing walk-through · CSU6 acceptance ·
CSU7 out of scope · CSU8 slices · CSU9 decisions.
A hard pity — the ceiling every real gacha banner has — needs a counter that
- increments by 1 on every pull whose result is not an SSR,
- resets to 0 in the same frame an SSR lands — natural or forced,
- and whose committed value routes the next pull — at the ceiling that pull is a guaranteed SSR.
loop-state/1 cannot express this. Proved — the two negative results are pinned
in src/engine/gacha-pity-timing.probe.test.ts
(merged with #178):
- a
pity → pityself-labelwith-Sapplies every Phase 0 unconditionally, so the counter is pinned at the single-step increment ([0, 1, 1, 1, …]) and never climbs to a ceiling; - a
trigger → Drainreset lands one step late — the SSR fires at step t, the drain emptiespityat t+1, but the routing at t+1 has already readS[pity](committed at t, still at the ceiling), so the forced-SSR route fires on two consecutive steps:ceiling fired at steps: [6, 7]forcap = 5.
From SEMANTICS-S.md §S2, §S4, §S5 (impl src/engine/step.ts Phase 0):
| primitive | when it acts | source | reads | conditional? |
|---|---|---|---|---|
trigger |
Phase 0, if source ∈ fired(t−1) (opt. delay) |
any node | — | yes — that is its gate |
activator |
Phase 0, gates the target's firing this step | Pool | S[source] vs a literal comparison (>= 5, < 2, …) |
gates firing only, not a label |
label |
Phase 0, every step, edits working[target] |
Pool | S[source] or a literal |
no — "always applies" (§S5) |
Phase 0 order: (a) deliver due triggers → (b) activator gates vs S → (c)
apply every label in ascending edge.id → (d) one clamp per touched Pool.
Then Phase 1 (push), Phase 2 (pull, builds fired), Commit (clamp every Pool).
labelis unconditional — no form means "apply only on a step where …". Assr → pitylabel=0zeroespityevery step.- A
labelsource must be a Pool, and a Pool neverfires.firedis only ever a Source (Phase 1) or a Phase-2 router — Gate / Converter / Drain / End;step.tsadds nothing else. So a Pool-sourcedlabelcannot be gated on "did a result happen". labelruns in Phase 0, before the pull. Whatever a Phase-0labelwrites reflects the previous step's result, so the on-screenpityis always one pull behind the roll the user just saw. (rev 1's Phase-0 scheme did not fix this — it only stopped the routing double-fire, not the value.)- An
activatorcomparison is literal-only —>= @capis not in the grammar (SEMANTICS-S.md§S6). A ceiling threshold must be a number, and a Parameter cannot drive it (CSU9-D2).
One addition: a label may run after the pull, gated on a node that fired
this step.
label gains an optional timing:
timing |
when it applies | source | expr |
|---|---|---|---|
(absent) / "phase0" |
Phase 0, unconditionally | Pool (unchanged §S5) | +N -N =N +S -S =S (unchanged) |
"afterPull" |
a new Phase 2.5, after the Phase 2 pull, before the Commit clamp | a node that fires in Phase 2 — a Gate, Converter, Drain, or End (a Source is not accepted: it fires in Phase 1 and is not a per-pull result; a Pool never fires) | numeric literal only: +N -N =N (N a finite real ≥ 0). +S -S =S are rejected — an afterPull source is not a Pool, so S[source] has no meaning |
An afterPull label requires a when (CSU3-2). A phase0 label must
not carry one (CSU3-5). A phase0-typed label is byte-identical to
loop-state/1 in both behaviour and report shape.
The only when value in v1 (CSU9-D3). The edit applies iff source ∈ fired for the step just pulled — the current step's fired set, built during
Phase 2 (not fired(t−1), which is what trigger reads). An afterPull
label whose source did not fire this step is inert — no diagnostic (it is
expected to be inert on most pulls), reported with applied: false (CSU3-4).
when reads a set membership, never another state edge's result, so Phase 2.5
stays a single forward pass with no ordering questions among the when gates.
Phase 0 (a) triggers (b) activator gates vs S (c) phase0 labels (d) clamp ← unchanged
Phase 1 push (Sources) ← unchanged
Phase 2 pull (Gate / Converter / Drain / End); builds `fired` ← unchanged
Phase 2.5 afterPull labels, ascending edge.id (NEW):
– an edge whose `when` source ∉ fired → inert, applied:false, skip
– else edit working[target] by the literal expr, against the
running working[target]; intermediate out-of-range is allowed
Commit clamp every Pool to [0, capacity] / floor-0 uncapped ← existing;
this single clamp IS the afterPull clamp (no separate one)
Phase 0's own label clamp (step d) is untouched. Nothing in Phase 2.5 touches
report.events, schedules a trigger, or sets ended — a firing End in
Phase 2 still ends the run, and the afterPull label on that final step still
applies, so the last committed value is correct.
To keep loop-state/2 reports byte-identical for every existing graph
(CSU9-D5):
- a
phase0/ untypedlabeleffect keeps its exactloop-state/2shape —{ kind: "label", delta, clampAdjustment }, noappliedfield. - an
afterPulllabeleffect is{ kind: "label", applied: boolean, delta, clampAdjustment }:applied: true— the edit ran;deltais its raw requested change.applied: false— thewhengate was not satisfied;delta: 0,clampAdjustment: 0, always (an inert edge never carries a clamp correction — CSU3-6).
- a consumer reads absent
appliedastrue.
timing: "afterPull"+ a Pool or Source source (afterPull label "<id>" needs a Gate / Converter / Drain / End source).timing: "afterPull"+ a+S -S =Sexpr (afterPull label "<id>" cannot read S; use a numeric literal).timing: "afterPull"with nowhen(afterPull label "<id>" needs when: "source-fired").timing: "phase0"(or absent) with awhen— the whole edge is inert (label "<id>" has a when but is not timing: "afterPull"; ignored). It is never run as an unconditional label — a condition the author set is never silently dropped (fail-closed).whenvalue other than"source-fired"(label "<id>" when "<v>" is not supported).- unknown
timing— inert (label "<id>" timing "<v>" is not supported).
Attribution. When several applied afterPull labels edit one Pool and
the Commit clamp then corrects it, the correction is attributed to that
target's last applied afterPull edge (ascending edge.id), as
clampAdjustment on its effect — the SEMANTICS-S2.md §S2-9 rule, scoped to
the Phase-2.5 edits. An inert edge is never the attribution target.
I1′. label deltas are an external source/sink term, separate from resource
movement (SEMANTICS-S.md §S5, §S12 I1′). A Pool's per-step balance change
decomposes into three disjoint terms:
final − start = (Σ resource in − Σ resource out) ← Phase 1 + Phase 2, `report.events`
+ (postPhase0 − start) ← Phase-0 label term
+ (commit − postPhase2Working) ← Phase-2.5 label term
- Phase-0 label term
= Σ (phase0 delta) + phase0 clampAdjustmentfor that Pool (unchanged fromloop-state/2). - Phase-2.5 label term
= Σ (applied afterPull delta) + afterPull clampAdjustmentfor that Pool.
The single equation Σ (applied delta) + clampAdjustment = final − start holds
only for a Pool with no incident resource edges — e.g. pity, which is
touched exclusively by label. For any Pool that also moves resource, the
three-term decomposition above is the invariant.
timing and when are new recognised fields on a state edge's data.
The GraphDoc envelope, schema string, model version, and node kinds are
unchanged; serialize() already carries data whole (see
[[serialize-schema-allowlist]]), so the fields round-trip. But — unlike route
/ waypoints / frames, which never touch a simulated number — timing /
when change the run. They are therefore engine-affecting fields:
- they move the content digest and set
dirty(like any edit), and - they move the engine / semantic digest and
simulationRev.
Byte-identical only for a fully legacy graph. An engine digest and report
byte-identical to loop-state/2 are produced only by a graph in which
every label edge has no when, and no timing (or a timing that
normalises to the legacy phase0). This is stricter than "no afterPull
edge": a phase0 label carrying a when, an unknown timing, or any other
leftover CSU field changes execution or a diagnostic (CSU3-5 is
fail-closed — such an edge goes inert where loop-state/1 would have applied
it), so it is engine-digest-affecting even with zero afterPull edges.
The new SEMANTICS-S* revision's loop-revision/N projection classifies
timing / when as engine-affecting (CSU9-D4). Slice 2's contract also
decides whether an explicit timing: "phase0" is omitted from the digest
projection — i.e. normalised to "absent" so a graph that only ever sets the
default stays byte-identical.
The ceiling is a fixed design constant HARD_PITY = 3 — a "3-pull ceiling":
the 3rd consecutive non-SSR pull is a guaranteed SSR. It is not a
parameter node — the activator grammar is literal-only, so a Parameter would
be editable but never reach routing, a misleading contract (CSU9-D2). The two
routing activators carry the literal HARD_PITY − 1 = 2; the acceptance fixture
asserts both the literal and the 3-pull behaviour. A user-tunable ceiling waits
for @parameter activator support (CSU7, a prerequisite for the 3-zone
Template).
Nodes — all shipped kinds:
pity— Pool,initial0, uncapped (CSU9-D1). Its committed value is the count of consecutive non-SSR pulls.roll_gate— probabilistic Gate,activator pity < 2. Branches →ssr/sr/r.forced_ssr— deterministic Gate,activator pity >= 2, single SSR out-edge. Exactly one ofroll_gate/forced_ssris enabled per pull.ssr_hit— a single-output deterministic Gate merging theroll_gateSSR branch andforced_ssr, →ssr_countPool. A Gate passes its whole input through its one output (no ConverterΣ inRatedemand), so a lone natural or forced SSR delivers exactly 1. Itfires⇔ any SSR this step.sr_hit/r_hit— single-output deterministic Gates on theroll_gatesr/rbranches, →sr_count/r_count. Eachfires⇔ that grade this step.
Not Converters. A Converter treats the sum of all its input-edge rates as one activation's demand, so a 2-input
ssr_hitConverter fed by only one branch would run atf = 0.5and bank0.5intossr_count. Deterministic Gates avoid this and keep every grade count integral andssr + sr + r == pullsexact.
State edges — all mode: "label", timing: "afterPull", when: "source-fired":
| edge | source → target | expr |
effect |
|---|---|---|---|
pity_reset |
ssr_hit → pity |
=0 |
pity → 0 in the SSR frame (natural or forced) |
pity_inc_sr |
sr_hit → pity |
+1 |
pity += 1 in an SR frame |
pity_inc_r |
r_hit → pity |
+1 |
pity += 1 in an R frame |
roll_gate / forced_ssr route exactly one branch per pull, so exactly
one of ssr_hit / sr_hit / r_hit fires, so exactly one afterPull
label applies — no ordering trick, no marker Pool.
The routing activators read S[pity] the ordinary way, because Phase 2.5
already committed the correct value on the previous pull.
pity column = the value committed at the end of that step — what the
Timeline shows for that frame.
| step | routing (reads S[pity]) |
roll result | fires | Phase 2.5 | committed pity |
|---|---|---|---|---|---|
| 1 | 0 < 2 → roll_gate |
R | r_hit |
pity_inc_r +1 |
1 |
| 2 | 1 < 2 → roll_gate |
SR | sr_hit |
pity_inc_sr +1 |
2 |
| 3 | 2 >= 2 → forced_ssr |
forced SSR | ssr_hit |
pity_reset =0 |
0 |
| 4 | 0 < 2 → roll_gate |
R | r_hit |
pity_inc_r +1 |
1 |
| 5 | 1 < 2 → roll_gate |
natural SSR | ssr_hit |
pity_reset =0 |
0 |
| 6 | 0 < 2 → roll_gate |
R | r_hit |
pity_inc_r +1 |
1 |
| 7 | 1 < 2 → roll_gate |
SR | sr_hit |
pity_inc_sr +1 |
2 |
- The 3rd pull is the guaranteed SSR — a real "3-pull hard pity", not a 4-pull one.
pity = 0in the exact frame the SSR lands (steps 3, 5) — not the next.+1in the exact frame a non-SSR lands.forced_ssrfires on step 3 only — the probe's[6, 7]becomes a single hit — because step 4 routes on the already-resetS[pity] = 0.- The SSR gap never exceeds
HARD_PITY;pityis0after every SSR;pityonly rises on a non-SSR pull.
- One result per paid pull — exactly one of
ssr_hit/sr_hit/r_hitfires;ssr_count + sr_count + r_count == pulls_madeexactly at every committed step, all integral. - Below the ceiling —
roll_gateroutes by probability;forced_ssrinert. - At the ceiling — on the
HARD_PITY-th consecutive non-SSR pull the routing readsS[pity] = HARD_PITY − 1 = 2,forced_ssrfires, that pull is a guaranteed SSR. - Same-frame reset — the committed
pityis0at the end of every SSR step (natural or forced), with no intervening frame at a non-zero value. - Same-frame increment — the committed
pityrises by exactly 1 at the end of every non-SSR step and by 0 at the end of every SSR step; never negative; never exceedsHARD_PITY − 1at a routing read. - No off-by-one routing — the forced route fires on exactly one step per
ceiling hit; the SSR-to-SSR gap never exceeds
HARD_PITY. - Determinism & digest — same graph + seed ⇒ byte-identical run incl. the
Phase 2.5 order; Reset returns every value to step 0. A fully legacy
graph — every
labelwith nowhenand notiming(or atimingthat normalises tophase0) — is byte-identical toloop-state/2in engine digest andreport. Any graph with anafterPulledge, aphase0+whenedge, or an unknowntiminghas a newsimulationRev/ engine digest — the fields are engine-affecting (CSU3-7). - Backward compat — a
labelwith notiming(ortiming: "phase0") and nowhenbehaves byte-identically toloop-state/1, and its report effect keeps the exactloop-state/2shape (noappliedfield); every existing state test passes unchanged;SEMANTICS-S.md§14 vectors re-run. - Reporting — every
afterPulleffect carriesapplied; an inertafterPulledge is exactly{ applied: false, delta: 0, clampAdjustment: 0 }; a clamp correction is attributed only to the target's last appliedafterPulledge. - I1′ conservation — the three-term decomposition of CSU3-6 holds for
every Pool every step:
final − start = (Σ resource in − Σ resource out) + (postPhase0 − start) + (commit − postPhase2Working), with each label term equal toΣ (applied delta) + clampAdjustmentfor its phase. The singleΣ delta + clampAdjustment = final − startform is asserted only forpityand any other Pool with no incident resource edge.
The gacha-pity-timing.probe.test.ts tripwire is rewritten to the positive
case (the CSU5 trace) and kept.
@paramin anactivatorexpression (>= @cap) — the grammar is literal-only. This is a smallloop-model-style extension and a prerequisite for the public 3-zone Template (a tunable ceiling per banner); it is not part of CSU. GS10-3 uses the fixedHARD_PITY = 3constant.- Soft pity (a rate that ramps near the ceiling) — needs a Gate branch
weight that reads state;
loop-model/ Engine-B territory (GS11-D2), notloop-state. CSU3 makes it reachable later; it is not built here. whenforms other than"source-fired"— e.g. a comparison against a Pool. Add later only if a concrete use case earns it (CSU9-D3).afterPulllabel targeting a resource edge's flow, or a non-Pool target — target stays Pool-only (SEMANTICS-S.md§S0 deferred list).- Per-target
all | anyactivator policy — v1 stays AND-only. - Pickup-banner state (is-pickup, guarantee-after-loss), pity carry-over between banners — compose on top of CSU3 but are their own design (the gacha 3-zone Template redesign).
- Design — this doc. Merges as settled design — implementation pending.
- Engine impl — Phase 2.5 in
src/engine/step.ts;timing/whenparse- the CSU3-5 validation in
src/engine/stateExpr.ts(shared with the Inspector); theappliedfield on theafterPulllabel effect (src/engine/types.ts+SEMANTICS-S2.md); a newSEMANTICS-S*.mdrevision whoseloop-revision/Nprojection classifiestiming/whenas engine-affecting (movessimulationRev+ the engine digest); the positive pity probe. No GraphDoc envelope /schema-string /model-version or node-kind change; the recognised state-edgedatacontract and the revision projection are extended.
- the CSU3-5 validation in
- Inspector —
timing/whenauthoring on astateedge; read-back copy; anapplied: falseTimeline cue. - Gacha hard pity (GS10-3) —
roll_gate/forced_ssr+ssr_hit/sr_hit/r_hitGates + the threeafterPulllabels of CSU4 land inexamples/gacha-simulator.jsonand its fixture; the ceiling is the fixed constantHARD_PITY = 3with the paired activator literal2(noparameternode); §GS9's pity conditions return. @parameteractivator support — the prerequisite from CSU7, before the public Template so the ceiling can be a per-banner Parameter.- 3-zone gacha Template — general / premium-standard / premium-pickup, pickup guarantee, pity carry-over. Own design doc; consumes CSU3 + GS10-3 + slice 5.
| id | decision |
|---|---|
| CSU9-D1 | pity is an uncapped Pool. A capacity would make the Commit clamp a second writer of the counter (pity + 1 → cap on the ceiling step). Uncapped keeps pity_reset =0 the single source of truth and the Timeline shows the raw count. |
| CSU9-D2 | The routing threshold is a fixed literal in the activator expr (< 2 / >= 2 for the 3-pull ceiling). No HARD_PITY parameter node — the activator grammar is literal-only, so an editable Parameter would change docs / cost math but not routing, a misleading contract. HARD_PITY = 3 is a design constant in the doc and the fixture; the fixture asserts activator literal == HARD_PITY − 1 and the 3-pull behaviour. A tunable per-banner ceiling waits for @parameter activator support (CSU7 / slice 5). |
| CSU9-D3 | The only when value in v1 is "source-fired". It unblocks the hard pity and the pickup-guarantee state; a comparison form (when: ">= n" @pool) is deferred until a concrete use case. |
| CSU9-D4 | timing / when are engine-affecting document content, not a cosmetic (route / waypoints / frames) field: they change the run, so they move the content digest + dirty and the engine / semantic digest + simulationRev. The new SEMANTICS-S* loop-revision/N projection classifies them as engine-affecting. Byte-identical to loop-state/2 (engine digest and report) holds only for a fully legacy graph: every label with no when and no timing (or a timing normalising to phase0). A phase0 + when edge, an unknown timing, or any leftover CSU field changes execution or a diagnostic (CSU3-5 fail-closed) and is engine-digest-affecting even with zero afterPull edges. Slice 2 also decides whether an explicit timing: "phase0" is omitted from the projection (normalised to "absent"). |
| CSU9-D5 | applied is added only to afterPull label effects; phase0 / untyped label effects keep the exact loop-state/2 shape. Consumers treat a missing applied as true. So a report shape does not change for anyone who does not use afterPull. |
| CSU9-D6 | Phase 2.5 walks all afterPull edges for a target in ascending edge.id against the running working[target]; intermediate out-of-range values are allowed and the single Commit clamp is the only clamp — identical semantics to SEMANTICS-S.md §S5(d), scoped to Phase 2.5. If a malformed graph makes two of ssr_hit / sr_hit / r_hit fire in one step, both labels apply in edge.id order (= last-wins, + sums); the gacha fixture asserts exactly one fires, and with roll_gate / forced_ssr routing exactly one branch this cannot occur in a valid graph. |