diff --git a/.edpa/backlog/defects/D-73.md b/.edpa/backlog/defects/D-73.md index a4afb57..5b45fe2 100644 --- a/.edpa/backlog/defects/D-73.md +++ b/.edpa/backlog/defects/D-73.md @@ -12,6 +12,14 @@ tc: 0 rr_oe: 0 wsjf: 0.0 evidence: +- type: commit_author + person: urbanek + weight: 0 + raw_weight: 4.0 + ref: commit/5b34ea4 + at: '2026-07-21T02:56:45+02:00' + tags: + - referenced - type: commit_author person: urbanek weight: 4.0 diff --git a/.edpa/backlog/defects/D-80.md b/.edpa/backlog/defects/D-80.md new file mode 100644 index 0000000..2b4f58f --- /dev/null +++ b/.edpa/backlog/defects/D-80.md @@ -0,0 +1,156 @@ +--- +id: D-80 +type: Defect +title: Net-negative revert scores silently void item credit +status: Done +created_at: '2026-07-21T00:02:28Z' +js: 2 +bv: 0 +tc: 0 +rr_oe: 0 +wsjf: 0.0 +evidence: +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/301480a + at: '2026-07-21T02:04:25+02:00' +- type: yaml_edit + person: urbanek + weight: 2.77 + raw_weight: 2.77 + discount: 1.0 + ref: commit/301480a/D-80.md + at: '2026-07-21T00:04:25+00:00' + tags: + - list_grow×3 + - scalar×2 + - vol+0.8 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 3 + list_items_removed: 0 + scalars_changed: 2 + lines_added: 32 + lines_removed: 1 +- type: agent_contribution + agent: claude-opus-4-8-(1m-context) + person: _claude + weight: 1.0 + ref: commit/301480a/agent/claude-opus-4-8-(1m-context) + at: '2026-07-21T02:04:25+02:00' +- type: commit_author + person: urbanek + weight: 0 + raw_weight: 4.0 + ref: commit/4ea5b4c + at: '2026-07-21T03:40:08+02:00' + tags: + - referenced +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/538f7fe + at: '2026-07-21T02:04:50+02:00' +- type: yaml_edit + person: urbanek + weight: 0.25 + raw_weight: 0.25 + discount: 1.0 + ref: commit/538f7fe/D-80.md + at: '2026-07-21T00:04:50+00:00' + tags: + - scalar×1 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 1 + lines_added: 2 + lines_removed: 1 +- type: state_transition + person: urbanek + weight: 0 + ref: commit/538f7fe/D-80/Funnel->Done + at: '2026-07-21T02:04:50+02:00' + from_status: Funnel + to_status: Done +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/926b409 + at: '2026-07-21T02:02:28+02:00' +- type: yaml_edit + person: urbanek + weight: 3.8 + raw_weight: 3.8 + discount: 1.0 + ref: commit/926b409/D-80.md + at: '2026-07-21T00:02:28+00:00' + tags: + - create + - scalar×6 + - vol+0.3 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 6 + lines_added: 12 + lines_removed: 0 +- type: state_transition + person: urbanek + weight: 0 + ref: commit/926b409/D-80/init->Funnel + at: '2026-07-21T02:02:28+02:00' + from_status: + to_status: Funnel +closed_at: '2026-07-21T00:04:34Z' +--- + +## Symptom + +An item whose signals net out negative for one contributor (e.g. a +`yaml_edit:revert` from grooming cleanup on someone else's Story) silently +lost ALL credit — for everyone, not just the reverter: + +| Signals on item | total | cw (Alice) | engine does | +|---|---|---|---| +| Alice commit 4.0, Bob revert −0.5 | 3.5 | **1.14** | hard-drops Alice (`0 <= cw <= 1` filter), drops Bob → item credited to nobody | + +No WARN fired anywhere: the Σcw≠1.0 drift check never sees the dropped +entries, so a Done Story with real evidence could vanish from the +timesheet without a trace. + +## Cause + +`aggregate_signals` let net-negative person scores into the cw math. A +negative share is incoherent in per-item normalization — it deflates the +denominator and pushes the other contributors above cw 1.0, which +`engine.extract_contributors` then hard-drops as out of range. The drop +was silent on top of that. + +Negative weights exist by design (`yaml_edit:revert` = −0.5/block, +anti-work semantics), but they are only meaningful as a reduction of the +SAME person's score, never as a cross-person share transfer. + +## Fix + +- `detect_contributors.aggregate_signals`: clamp per-person net scores at + 0 before normalization; the raw (negative) score stays in + `contribution_score` for audit and the clamp fires a stderr WARNING. + All-negative items return None (existing warn-and-skip path). +- `engine.extract_contributors`: the out-of-range cw drop now prints + `WARN: : contributors[] entry for has cw=X outside [0,1]` + (covers files written before the clamp and hand-written frontmatter). +- A revert from the SAME person still reduces their own score — clamping + only kicks in at the net-negative boundary, not per signal. + +Tests: 3 aggregate cases (clamp-to-zero, all-negative → None, own-revert) ++ 1 engine WARN case. Full suite 1219 passed. diff --git a/.edpa/backlog/defects/D-81.md b/.edpa/backlog/defects/D-81.md new file mode 100644 index 0000000..7eb2401 --- /dev/null +++ b/.edpa/backlog/defects/D-81.md @@ -0,0 +1,168 @@ +--- +id: D-81 +type: Defect +title: Gate bounce double-credits ladder edges — per-item gate credit must budget to <= 1.0 x js +status: Done +created_at: '2026-07-21T00:39:32Z' +js: 3 +bv: 0 +tc: 0 +rr_oe: 0 +wsjf: 0.0 +evidence: +- type: commit_author + person: urbanek + weight: 0 + raw_weight: 4.0 + ref: commit/4ea5b4c + at: '2026-07-21T03:40:08+02:00' + tags: + - referenced +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/52a9198 + at: '2026-07-21T02:39:32+02:00' +- type: yaml_edit + person: urbanek + weight: 3.8 + raw_weight: 3.8 + discount: 1.0 + ref: commit/52a9198/D-81.md + at: '2026-07-21T00:39:32+00:00' + tags: + - create + - scalar×6 + - vol+0.3 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 6 + lines_added: 12 + lines_removed: 0 +- type: state_transition + person: urbanek + weight: 0 + ref: commit/52a9198/D-81/init->Funnel + at: '2026-07-21T02:39:32+02:00' + from_status: + to_status: Funnel +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/dfb296d + at: '2026-07-21T02:57:01+02:00' +- type: yaml_edit + person: urbanek + weight: 0.25 + raw_weight: 0.25 + discount: 1.0 + ref: commit/dfb296d/D-81.md + at: '2026-07-21T00:57:01+00:00' + tags: + - scalar×1 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 1 + lines_added: 2 + lines_removed: 1 +- type: state_transition + person: urbanek + weight: 0 + ref: commit/dfb296d/D-81/Funnel->Done + at: '2026-07-21T02:57:01+02:00' + from_status: Funnel + to_status: Done +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/e701798 + at: '2026-07-21T02:48:58+02:00' +- type: yaml_edit + person: urbanek + weight: 3.5 + raw_weight: 3.5 + discount: 1.0 + ref: commit/e701798/D-81.md + at: '2026-07-21T00:48:58+00:00' + tags: + - list_grow×4 + - scalar×2 + - vol+1.0 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 4 + list_items_removed: 0 + scalars_changed: 2 + lines_added: 43 + lines_removed: 1 +- type: agent_contribution + agent: claude-opus-4-8-(1m-context) + person: _claude + weight: 1.0 + ref: commit/e701798/agent/claude-opus-4-8-(1m-context) + at: '2026-07-21T02:48:58+02:00' +closed_at: '2026-07-21T00:52:00Z' +--- + +## Symptom + +A Feature that bounced through QA (Validating → Implementing → Validating) +accumulated gate credit beyond its Job Size — each crossing of +`Implementing→Validating` credited another `0.50 × js`: + +``` +Funnel→Analyzing 0.10 +Analyzing→Backlog 0.05 +Backlog→Implementing 0.05 +Implementing→Validating 0.50 +Validating→Implementing 0.143 ← not in gate_weights → equal-split fallback +Implementing→Validating 0.50 ← SECOND crossing, credited again +... ---- +total 1.64 × js (ladder budget is 1.0) +``` + +Two side-effects of the same root cause: the equal-split fallback credited +a REOPEN (~0.143) more than `Releasing→Done` (0.10), and a forward jump +(Funnel→Done) got an arbitrary 1/N instead of the work it compressed. + +## Cause + +Gate credit was computed per TRANSITION (`weight = gate_weights[key]` per +occurrence), while the config contract is per LIFECYCLE ("weights split the +parent's Job Size across its lifecycle, MUST sum to 1.0"). Reaching a +milestone twice doesn't extend the milestone ladder. There was no per-edge +dedup and no budget replay across iterations. + +## Fix + +Gate credit is now a **budget replayed over the item's full transition +history** (`load_gate_events`): + +- the gate_weights table is parsed into a status ladder + (`_build_gate_ladder`); each ladder edge is credited at most once per + item, the first time any recorded transition crosses it — re-crossings + earn nothing ("already credited" note); +- backward moves (reopens) earn nothing — the rework they represent is + credited through the Story / commit_author / yaml_edit channels; +- a forward JUMP over skipped statuses credits the sum of the bypassed + edges (Funnel→Backlog = 0.10 + 0.05); +- off-ladder transitions earn nothing + WARN (equal-split fallback + removed); creation (init→first status) stays analytics-only. + +Budget replays over ALL materialized transitions chronologically, so an +edge credited in iteration N stays spent in iteration N+k. Total gate +credit per item is bounded at 1.0 × js, as the config contract states. + +Tests: bounce-credits-each-edge-once, forward-jump-sums-bypassed-edges, +budget-replays-across-iterations, unknown-gate-no-credit, revert-does-not- +subtract (rewritten). Full suite green. diff --git a/.edpa/backlog/defects/D-82.md b/.edpa/backlog/defects/D-82.md new file mode 100644 index 0000000..e4b221f --- /dev/null +++ b/.edpa/backlog/defects/D-82.md @@ -0,0 +1,168 @@ +--- +id: D-82 +type: Defect +title: Gate events inherit all-time contributors — cross-iteration misattribution, prefer in-window evidence +status: Done +created_at: '2026-07-21T00:39:33Z' +js: 3 +bv: 0 +tc: 0 +rr_oe: 0 +wsjf: 0.0 +evidence: +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/14e04b6 + at: '2026-07-21T02:58:21+02:00' +- type: commit_author + person: urbanek + weight: 0 + raw_weight: 4.0 + ref: commit/4ea5b4c + at: '2026-07-21T03:40:08+02:00' + tags: + - referenced +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/5b34ea4 + at: '2026-07-21T02:56:45+02:00' +- type: yaml_edit + person: urbanek + weight: 1.68 + raw_weight: 1.68 + discount: 1.0 + ref: commit/5b34ea4/D-82.md + at: '2026-07-21T00:56:45+00:00' + tags: + - scalar×3 + - vol+0.9 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 3 + lines_added: 38 + lines_removed: 1 +- type: agent_contribution + agent: claude-opus-4-8-(1m-context) + person: _claude + weight: 1.0 + ref: commit/5b34ea4/agent/claude-opus-4-8-(1m-context) + at: '2026-07-21T02:56:45+02:00' +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/75cc42a + at: '2026-07-21T02:39:33+02:00' +- type: yaml_edit + person: urbanek + weight: 3.8 + raw_weight: 3.8 + discount: 1.0 + ref: commit/75cc42a/D-82.md + at: '2026-07-21T00:39:33+00:00' + tags: + - create + - scalar×6 + - vol+0.3 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 6 + lines_added: 12 + lines_removed: 0 +- type: state_transition + person: urbanek + weight: 0 + ref: commit/75cc42a/D-82/init->Funnel + at: '2026-07-21T02:39:33+02:00' + from_status: + to_status: Funnel +- type: commit_author + person: urbanek + weight: 4.0 + raw_weight: 4.0 + ref: commit/dfb296d + at: '2026-07-21T02:57:01+02:00' +- type: yaml_edit + person: urbanek + weight: 0.25 + raw_weight: 0.25 + discount: 1.0 + ref: commit/dfb296d/D-82.md + at: '2026-07-21T00:57:01+00:00' + tags: + - scalar×1 + delta: + blocks_added: 0 + blocks_removed: 0 + list_items_added: 0 + list_items_removed: 0 + scalars_changed: 1 + lines_added: 2 + lines_removed: 1 +- type: state_transition + person: urbanek + weight: 0 + ref: commit/dfb296d/D-82/Funnel->Done + at: '2026-07-21T02:57:01+02:00' + from_status: Funnel + to_status: Done +closed_at: '2026-07-21T00:52:00Z' +--- + +## Symptom + +Gate events inherited the parent's ALL-TIME contributors[] — a contributor +kept scoring at gates in iterations they never worked the item: + +``` +Feature F-100, js=20 +iter 1 (Jan): Alice analyzes, edits F-100.md, LBC → contributors [A 1.0] +iter 2 (Feb): Bob implements, Alice does NOTHING here → contributors [A 0.4, B 0.6] +Feb gate Implementing→Validating (0.50 × 20 = 10 js): + → Alice scores 4.0 in an iteration she did not touch the feature + → with no other Feb items, her ENTIRE Feb capacity lands on Bob's gate +``` + +## Cause + +`_passthrough_contributors` was the FIRST attribution step for gate events. +The per-iteration proportional normalization then distributed each person's +capacity over scores that included ghost shares from earlier iterations. + +D-73 fixed the identical problem for story-activity events +(`_activity_contributors` recomputes shares from in-window signals only); +gate events were the only synthetic event kind without that principle. + +## Fix + +Gate attribution is now a chain, recorded per event in the gate_events +audit (`attribution` field): + +1. `window` — shares recomputed from the parent's IN-WINDOW credit-bearing + evidence (yaml_edit / commit_author / manual), same math as D-73's + `_activity_contributors`. Excludes agent_contribution (_claude would + dilute human shares), zero-weight and out_of_iteration signals. +2. `passthrough` — all-time contributors[] when no in-window signals exist + (pre-D-82 behavior, now a fallback instead of the default). +3. `author` — the transition author at cw=1.0 when the parent has no + contributors[] either (v1.17.1 fallback preserved). + +Trade-off acknowledged: the passthrough-first model had a defensible +"lifecycle ownership" semantic (the designer shares every later gate), but +it collided with per-iteration capacity normalization and contradicted the +D-73 precedent. Window-first makes the two synthetic event kinds +consistent: credit the people who did the work in THIS iteration. + +Tests: 4 new (window split bob/carol, passthrough fallback, author +fallback, out-of-window + agent exclusions). Docs: methodology, RUNBOOK, +cw_heuristics template. Full suite 1226 passed. diff --git a/.edpa/config/id_counters.yaml b/.edpa/config/id_counters.yaml index bffb071..af06d66 100644 --- a/.edpa/config/id_counters.yaml +++ b/.edpa/config/id_counters.yaml @@ -1,5 +1,5 @@ counters: - Defect: 79 + Defect: 82 Epic: 15 Event: 2 Feature: 126 diff --git a/.edpa/engine/VERSION b/.edpa/engine/VERSION index db65e21..f48f82f 100644 --- a/.edpa/engine/VERSION +++ b/.edpa/engine/VERSION @@ -1 +1 @@ -2.21.0 +2.22.0 diff --git a/.edpa/engine/scripts/detect_contributors.py b/.edpa/engine/scripts/detect_contributors.py index 6c533cf..8148335 100644 --- a/.edpa/engine/scripts/detect_contributors.py +++ b/.edpa/engine/scripts/detect_contributors.py @@ -20,9 +20,17 @@ Aggregation: contribution_score[P, item] = Σ signal_weights for person P on item - cw[P, item] = score[P, item] / Σ_persons score[*, item] + cw[P, item] = clamp(score[P, item], 0) / Σ_persons clamp(score[*, item], 0) → Σ_persons cw[*, item] = 1.0 (per-item invariant) + Net-negative person scores (revert-only activity, e.g. grooming cleanup + via yaml_edit:revert) clamp to 0 for the cw computation: a negative + share has no meaning in per-item normalization, and letting it through + both inflates the other contributors above cw 1.0 (which the engine + hard-drops as out of range — silently voiding their credit) and breaks + the Σ cw = 1.0 invariant the snapshot audit relies on. The raw score + stays in contribution_score for the audit trail. + Modes: 1. CI mode (env-driven, used by edpa-contributor-detect.yml): PR_NUMBER, PR_AUTHOR, PR_TITLE, PR_BRANCH set by workflow @@ -507,7 +515,6 @@ def aggregate_signals(signals: list[dict], # Resolve every signal's login → person_id, normalising case. by_person: dict[str, list[dict]] = defaultdict(list) - total_score = 0.0 unknown: set[str] = set() for sig in signals: # Zero-weight signals (e.g. state_transition) are analytics-only @@ -524,7 +531,6 @@ def aggregate_signals(signals: list[dict], # `login` field, add resolved person id later in contributor entry. clean = {k: v for k, v in sig.items() if k != "login"} by_person[person_id].append(clean) - total_score += sig["weight"] # Surface tokens that resolved to neither a known github handle nor a # person id — these are credited as-is and the engine awards 0h, so a # silent typo would otherwise vanish without a trace. @@ -534,19 +540,40 @@ def aggregate_signals(signals: list[dict], f"unless it is a real person id; typo or external contributor?).", file=sys.stderr) - if total_score <= 0: - return None - - # Sort persons by contribution_score desc for deterministic YAML order. - contributors: list[dict] = [] + # Per-person net scores. Negative-weight signals (yaml_edit:revert) + # reduce the score of the person who made them — anti-work semantics. + # A person whose signals net NEGATIVE overall is clamped to 0 for the + # cw share: without the clamp their negative score deflates the + # denominator, pushing the other contributors above cw 1.0 — and the + # engine hard-drops out-of-range cw (extract_contributors requires + # 0 <= cw <= 1), silently voiding the legitimate contributor's credit + # on the item. Clamping keeps every cw inside [0, 1] and Σ cw = 1.0. person_scores = [ (pid, sum(s["weight"] for s in sigs), sigs) for pid, sigs in by_person.items() ] + clamped: dict[str, float] = {} + clamped_total = 0.0 + for pid, raw, _sigs in person_scores: + if raw < 0: + print( + f"WARNING: {pid}: net contribution score {raw:.2f} is " + f"negative (revert-only activity?) — clamped to 0 for the " + f"cw share; raw score kept in contribution_score for audit.", + file=sys.stderr, + ) + clamped[pid] = max(0.0, raw) + clamped_total += clamped[pid] + + if clamped_total <= 0: + return None + + # Sort persons by contribution_score desc for deterministic YAML order. + contributors: list[dict] = [] person_scores.sort(key=lambda x: (-x[1], x[0])) for pid, score, sigs in person_scores: - cw = score / total_score + cw = clamped[pid] / clamped_total # Sort signals deterministic by (type, ref) so two detect runs on # identical GH state produce byte-identical YAML. sigs_sorted = sorted(sigs, key=lambda s: (s["type"], s["ref"])) diff --git a/.edpa/engine/scripts/engine.py b/.edpa/engine/scripts/engine.py index a88887d..65b9261 100644 --- a/.edpa/engine/scripts/engine.py +++ b/.edpa/engine/scripts/engine.py @@ -98,6 +98,17 @@ def extract_contributors(item): except (TypeError, ValueError): continue if not 0 <= cw_val <= 1: + # Out-of-range cw used to be dropped SILENTLY — the person lost + # all credit on the item with no trace (reachable via net- + # negative revert math before the aggregate_signals clamp, or + # via hand-written frontmatter). Keep the drop (a share outside + # [0,1] is incoherent) but surface it loudly. + print( + f"WARN: {item.get('id', '?')}: contributors[] entry for " + f"{person!r} has cw={cw_val} outside [0,1] — entry skipped. " + f"Re-run detect_contributors to recompute.", + file=sys.stderr, + ) continue out.append({ "person": person, @@ -720,6 +731,45 @@ def _yaml_edit_from_evidence(edpa_root, start=None, end=None): return out +def _gate_credit_signals_from_evidence(edpa_root, start=None, end=None): + """Read windowed, credit-bearing signals from parent items' evidence[] + for gate-event attribution (D-82). + + Like _yaml_edit_from_evidence but across every signal type that + represents attributable work on the parent (yaml_edit, commit_author, + manual:*). Excluded: + - zero-weight signals (state_transition is analytics-only by design) + - out_of_iteration-tagged signals (D-62 neutralized: audit-visible, + non-crediting) + - agent_contribution (person is _claude — not a people.yaml member; + its share would evaporate in run_edpa and dilute the real shares) + + Returns {item_id: [signal, ...]} for Feature/Epic/Initiative items. + """ + edpa_root = Path(edpa_root) + out: dict[str, list] = {} + for sub in GATE_TYPE_DIRS.values(): + dir_path = edpa_root / "backlog" / sub + if not dir_path.is_dir(): + continue + for f in sorted(dir_path.glob("*.md")): + data = load_yaml(f) or {} + item_id = data.get("id") + if not item_id: + continue + for s in _read_evidence(data): + if not s.get("weight"): + continue + if s.get("type") == "agent_contribution": + continue + if "out_of_iteration" in (s.get("tags") or []): + continue + if not _in_window(s.get("at"), start, end): + continue + out.setdefault(item_id, []).append(s) + return out + + def _activity_contributors(sigs): """D-73: contributor shares for a story-activity event, scoped to the signals actually in THIS iteration's window. @@ -876,20 +926,89 @@ def load_story_activity_events(edpa_root, iteration_id, heuristics, return events, audit +def _build_gate_ladder(weights): + """Parse an ordered ``gate_weights`` table into a status ladder. + + Returns ``(ladder, edge_weights, standalone)``: + - ``ladder[i] -> ladder[i+1]`` carries weight ``edge_weights[i]`` + - ``standalone`` maps ``"A→B" -> weight`` for table entries that did + not fit the walked chain (credited independently, deduped per item) + + The table is authored ladder-ordered (Funnel→…→Done); the walk starts + at the first pair and follows each ``to`` into the next ``from``. + """ + pairs = [] + for key, w in (weights or {}).items(): + a, sep, b = str(key).partition("→") + if not sep: + a, sep, b = str(key).partition("->") + if not sep: + continue + pairs.append((a.strip(), b.strip(), float(w))) + if not pairs: + return [], [], {} + ladder = [pairs[0][0], pairs[0][1]] + edge_weights = [pairs[0][2]] + used = {0} + progressed = True + while progressed: + progressed = False + for i, (a, _b, _w) in enumerate(pairs): + if i not in used and a == ladder[-1]: + ladder.append(pairs[i][1]) + edge_weights.append(pairs[i][2]) + used.add(i) + progressed = True + standalone = { + f"{a}→{b}": w for i, (a, b, w) in enumerate(pairs) if i not in used + } + return ladder, edge_weights, standalone + + def load_gate_events(edpa_root, iteration_id, heuristics, people=None): """Convert status transitions into scoring 'events' for mode=gates. - For Feature/Epic/Initiative parents, every status transition that occurred - within iteration_id's date window becomes an item-shaped event with - job_size = parent.js * gate_weights[type][transition]. Each event reuses + For Feature/Epic/Initiative parents, status transitions within + iteration_id's date window become item-shaped events with + job_size = parent.js × (credited gate weight). Each event reuses its parent's contributor list as evidence, so run_edpa() scores it with the same math as a Done item. - v1.17.1 fix (Finding #1): when the parent has no contributors[] (typical - for IP-iter strategic items seeded with title+js but no team yet), fall - back to crediting the transition's commit author (`changed_by`) at cw=1.0. - Without this fallback, IP iterations with real strategic work derive 0h - because gate events inherit empty contributor lists. + D-81: gate credit is a BUDGET, not per-transition cash. The + gate_weights table splits parent.js across the status ladder (weights + sum to 1.0 by config contract — test_template_gate_weights_sum_to_one): + + * each ladder edge is credited AT MOST ONCE per item, the first time + any recorded transition crosses it — a QA bounce that re-crosses + Implementing→Validating earns no second 0.50 × js; + * backward moves (reopens, e.g. Validating→Implementing) earn + nothing — the rework they represent is credited through the + Story / commit_author / yaml_edit channels; + * a forward JUMP over skipped statuses credits the sum of the + bypassed edges (the work happened, compressed); + * transitions whose statuses don't resolve on the ladder earn + nothing (WARN) — the pre-D-81 equal-split fallback credited an + arbitrary 1/N of parent.js, which could out-weight real gates. + + The budget replays over ALL materialized transitions chronologically + (not just this iteration's window), so an edge credited in an earlier + iteration is never re-credited here. Per item this bounds total gate + credit at 1.0 × parent.js, as the config contract states. + + D-82: contributor attribution follows a chain, recorded per event in + the audit's ``attribution`` field: + + 1. ``window`` — shares recomputed from the parent's IN-WINDOW + credit-bearing evidence (yaml_edit / commit_author / manual), + the same math as _activity_contributors (D-73). Credits whoever + worked the parent in THIS iteration, not whoever touched it + across all time (cross-iteration ghost credit). + 2. ``passthrough`` — the parent's all-time contributors[], when no + in-window credit signals exist. + 3. ``author`` — the transition's commit author (`changed_by`) at + cw=1.0, when the parent has no contributors[] either (v1.17.1 + Finding #1: IP-iter strategic items seeded with title+js but no + team yet would otherwise derive 0h). Stories are NOT emitted here — they continue to flow through load_backlog_items() with the Done filter. @@ -918,12 +1037,20 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): # D-26: read materialized state_transition signals from evidence[] — the # engine is a pure reader and no longer scans git (detect_transitions). The # post-commit hook / `--materialize` persist transitions; we read the snapshot. - transitions = _transitions_from_evidence(edpa_root, start, end) + # D-81: UNWINDOWED read — the full chronological history is needed to + # replay each item's gate-edge budget (edges credited in earlier + # iterations must stay spent); in-window filtering happens per transition + # below via _in_window. + all_transitions = _transitions_from_evidence(edpa_root) gate_weights = (heuristics or {}).get("gate_weights", {}) or {} + ladders = {t: _build_gate_ladder(w) for t, w in gate_weights.items()} + spent: dict[str, dict] = {} # item_id -> {"edges": set[int], "keys": set[str]} + # D-82: windowed credit-bearing parent evidence for the attribution chain. + windowed_credit = _gate_credit_signals_from_evidence(edpa_root, start, end) events = [] audit = [] - for t in transitions: + for t in all_transitions: item_type = t["item_type"] # Stories are surfaced by transitions.py for audit/debug visibility, # but engine gates mode credits stories only at status=Done (handled @@ -931,17 +1058,80 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): # don't double-count. if item_type == "Story": continue - weights = gate_weights.get(item_type, {}) or {} - gate_key = f"{t['from_status']}→{t['to_status']}" - weight = weights.get(gate_key) - if weight is None and weights: - weight = round(1.0 / len(weights), 4) - print( - f"WARN: no gate_weight for {item_type} '{gate_key}', " - f"using equal-split fallback {weight}", - file=sys.stderr, - ) - if not weight or weight <= 0: + if item_type not in ladders: + continue + ladder, edge_weights, standalone = ladders[item_type] + if not ladder: + continue + pos = {s: i for i, s in enumerate(ladder)} + frm, to = t.get("from_status"), t.get("to_status") + if not frm: + # Creation (init → first status): analytics-only. The authoring + # work is already credited by the item's yaml_edit:create signal. + continue + gate_key = f"{frm}→{to}" + item_spent = spent.setdefault(t["item_id"], {"edges": set(), "keys": set()}) + in_window = _in_window(t.get("changed_at"), start, end) + + # Classify the transition against the ladder. + crossed: list[int] = [] # ladder edge indices this transition crosses + stand_key = None # standalone (off-chain) edge key + backward = False + if frm in pos and to in pos: + if pos[to] > pos[frm]: + crossed = list(range(pos[frm], pos[to])) + else: + backward = True # reopen / self-move: no lifecycle progress + elif gate_key in standalone: + stand_key = gate_key + + if backward or (not crossed and stand_key is None): + if in_window: + if backward: + print( + f"note: {t['item_id']} '{gate_key}' moves backward on " + f"the {item_type} ladder - no gate credit " + f"(reopen; rework credited via Story/commit/yaml_edit)", + file=sys.stderr, + ) + else: + print( + f"WARN: {t['item_id']}: no gate_weight for {item_type} " + f"'{gate_key}' and it does not resolve on the status " + f"ladder - no credit. Add an explicit edge to " + f"gate_weights to credit it.", + file=sys.stderr, + ) + continue + + # Spend budget (always, even out-of-window — replay) and compute the + # freshly credited weight (in-window, unspent edges only). + weight = 0.0 + if crossed: + fresh = [i for i in crossed if i not in item_spent["edges"]] + item_spent["edges"].update(crossed) + if in_window: + if fresh: + weight = round(sum(edge_weights[i] for i in fresh), 6) + else: + print( + f"note: {t['item_id']} '{gate_key}' already credited by " + f"an earlier crossing - no double credit", + file=sys.stderr, + ) + else: + fresh_key = stand_key not in item_spent["keys"] + item_spent["keys"].add(stand_key) + if in_window: + if fresh_key: + weight = standalone[stand_key] + else: + print( + f"note: {t['item_id']} '{gate_key}' already credited by " + f"an earlier crossing - no double credit", + file=sys.stderr, + ) + if not in_window or weight <= 0: continue sub = GATE_TYPE_DIRS.get(item_type) @@ -966,7 +1156,19 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): effective_js = round(parent_js * weight, 6) synth_id = f"{t['item_id']}@{t['from_status'] or 'init'}->{t['to_status']}" - contribs = _passthrough_contributors(parent) + # D-82 attribution chain — credit whoever worked the parent in THIS + # iteration first (shares recomputed from its in-window evidence, + # the same math as _activity_contributors / D-73), then the all-time + # contributors[] passthrough, then the transition author (v1.17.1). + # Pre-D-82 the passthrough ran first, so a contributor from an + # earlier iteration kept scoring at gates long after they stopped + # working the item (cross-iteration misattribution). + contribs = _activity_contributors( + windowed_credit.get(t["item_id"]) or []) + attribution = "window" + if not contribs: + contribs = _passthrough_contributors(parent) + attribution = "passthrough" if not contribs: # v1.17.1 fallback: parent has no contributors[] yet → credit the # transition's git author. Resolves email/login via people.yaml. @@ -977,6 +1179,7 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): if not resolved and "@" in changed_by: resolved = resolver.get(changed_by.split("@", 1)[0]) if resolved: + attribution = "author" contribs = [{ "person": resolved, "cw": 1.0, @@ -990,6 +1193,8 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): "detected_at": t.get("changed_at"), }], }] + else: + attribution = None events.append({ "id": synth_id, @@ -1007,6 +1212,11 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): "changed_at": t["changed_at"], "changed_by": t["changed_by"], "commit_hash": t["commit_hash"], + # D-82: which attribution path produced the contributors — + # "window" (in-iteration evidence shares), "passthrough" + # (all-time contributors[]), "author" (transition author), or + # None when nobody could be credited. + "attribution": attribution, }) return events, audit diff --git a/.edpa/engine/templates/cw_heuristics.yaml.tmpl b/.edpa/engine/templates/cw_heuristics.yaml.tmpl index c523311..207f8fe 100644 --- a/.edpa/engine/templates/cw_heuristics.yaml.tmpl +++ b/.edpa/engine/templates/cw_heuristics.yaml.tmpl @@ -48,8 +48,12 @@ signals: # Weights split the parent's Job Size across its lifecycle, so prep # work (LBC, decomposition, validation) is credited as it happens — # not only at terminal Done. Weights MUST sum to 1.0 per item type. -# The gate event inherits the parent's contributors[] verbatim -# (per-item shares are unchanged by the gate split). +# D-81: the table is a per-lifecycle BUDGET — each edge credits at most +# once per item (QA bounces / reopens earn no second credit; a forward +# jump credits the bypassed edges' sum), so total gate credit per item +# stays <= 1.0 x JS. D-82: gate contributors follow the attribution +# chain — in-window evidence shares, else the parent's contributors[], +# else the transition author. # # Stories stay Done-only and flow through the regular backlog scan; # they are never emitted as gate events. diff --git a/.edpa/engine/templates/edpa.yaml.tmpl b/.edpa/engine/templates/edpa.yaml.tmpl index 819047c..e677227 100644 --- a/.edpa/engine/templates/edpa.yaml.tmpl +++ b/.edpa/engine/templates/edpa.yaml.tmpl @@ -64,7 +64,7 @@ project: governance: # Auto-stamped to the installed plugin version by install.sh / project_setup.py. - methodology: "EDPA 2.21.0" + methodology: "EDPA 2.22.0" # v1.14: single calculation path. v1.17: yaml_edit signals — every # commit on .edpa/backlog//.md contributes structural credit # (create / block_add / list_grow / scalar_change / lines_volume / diff --git a/CHANGELOG.md b/CHANGELOG.md index bf36bfb..c6eb156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 2.22.0 — 2026-07-21 + +Three credit-allocation fixes that change how derived hours distribute +across items and people — the per-item Σcw = 1.0 and Σ hours = capacity +invariants are untouched. Closed iteration snapshots are not rewritten +retroactively, but re-running an old iteration now yields corrected +numbers. Note: the Monte Carlo validation figures in docs (MAD 7.8 %) +were measured against the old model and are due for re-validation. + +### Fixed + +- **Net-negative contribution scores silently voided an item's entire + credit (D-80).** A contributor whose signals netted negative + (`yaml_edit:revert` from grooming cleanup on someone else's item) + deflated the per-item cw denominator, pushing the legitimate + contributors above cw 1.0 — which `engine.extract_contributors` + hard-drops as out of range, silently voiding **all** credit on the + item with no WARN anywhere. `aggregate_signals` now clamps per-person + net scores at 0 before normalization; the raw negative score stays in + `contribution_score` for audit and the clamp warns on stderr. + All-negative items return None (existing warn-and-skip path). A revert + from the same person still reduces their own score — the clamp only + kicks in at the net-negative boundary, not per signal. + `extract_contributors` also warns when dropping out-of-range cw + (covers files written before the clamp and hand-written frontmatter). +- **QA bounces double-credited gate edges — gate credit is now a + per-lifecycle budget (D-81).** A bounce re-crossing + Implementing→Validating credited 0.50 × js on **every** crossing + (1.64 × js total for one bounce); a reopen hit the equal-split + fallback (~0.143 — more than Releasing→Done); a forward jump got an + arbitrary 1/N instead of the bypassed edges it compressed; item + creation (Init→Funnel) silently earned a phantom ~0.143. + `load_gate_events` now replays each item's gate-edge budget over its + full materialized transition history: each ladder edge credits at most + once (first crossing), backward moves and re-crossings earn nothing, + forward jumps sum the bypassed edges, and off-ladder transitions earn + nothing with a WARN (the equal-split fallback is removed). Total gate + credit per item is bounded at 1.0 × js, matching the `gate_weights` + config contract. +- **Gate events inherited all-time contributors — attribution now + prefers in-window evidence (D-82).** Gate events took the parent's + **all-time** `contributors[]` as their first attribution step, so a + contributor from an earlier iteration kept scoring at gates in + iterations they never worked the item — the per-iteration proportional + normalization then spread their capacity over ghost shares. + Attribution is now a chain, recorded per event in the gate_events + audit (`attribution` field): **(1) window** — shares recomputed from + the parent's in-window credit-bearing evidence (same math as D-73's + `_activity_contributors`; agent_contribution and neutralized signals + excluded), **(2) passthrough** — all-time `contributors[]` when no + in-window signals exist, **(3) author** — the transition author at + cw=1.0 (v1.17.1 fallback preserved). + + Docs updated: methodology.md (gate budget + attribution chain), + RUNBOOK.md, `cw_heuristics.yaml.tmpl` (plugin + vendored copy), + playbook CZ + web playbook CZ/EN. + ## 2.21.0 — 2026-07-20 The commit-msg escape hatch now works in repos that enforce Conventional diff --git a/README.md b/README.md index 3015a8b..a579c44 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Derive hours from Git evidence. No timesheets.** -[![EDPA](https://img.shields.io/badge/EDPA-2.21.0-34d399)](docs/methodology.md) +[![EDPA](https://img.shields.io/badge/EDPA-2.22.0-34d399)](docs/methodology.md) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![GitHub](https://img.shields.io/badge/Made_for-GitHub-181717?logo=github)](https://github.com) @@ -29,7 +29,7 @@ Monday morning: "What did I work on last week? Let me guess... 4h on S-200, mayb ``` $ python3 .edpa/engine/scripts/engine.py --edpa-root .edpa --iteration PI-2026-1.3 -EDPA 2.21.0 — Iteration PI-2026-1.3 +EDPA 2.22.0 — Iteration PI-2026-1.3 ====================================================================== Person Role Capacity Derived Items OK ---------------------------------------------------------------------- @@ -94,7 +94,7 @@ Bootstrapping .edpa/ data tree... Created .edpa/config/people.yaml (edit with your team) Created .edpa/config/edpa.yaml (edit project.name + governance metadata) -EDPA 2.21.0 installed. +EDPA 2.22.0 installed. ``` The installer vendors the engine to `.edpa/engine/` (it installs no pip @@ -137,7 +137,7 @@ python3 .edpa/engine/scripts/engine.py --status ``` ``` -EDPA 2.21.0 — Status +EDPA 2.22.0 — Status ======================================== ✓ .edpa/ found at .edpa ✓ people.yaml — 2 members, 1.5 FTE, 60h/iteration @@ -221,7 +221,7 @@ Snapshot frozen: .edpa/snapshots/PI-2026-1.1.json Excel: .edpa/reports/iteration-PI-2026-1.1/edpa-results.xlsx ====================================================================== -EDPA 2.21.0 — Iteration PI-2026-1.1 +EDPA 2.22.0 — Iteration PI-2026-1.1 ====================================================================== Person Role Capacity Derived Items OK ---------------------------------------------------------------------- @@ -449,7 +449,7 @@ python3 .edpa/engine/scripts/backlog.py add --type Story --title "..." | Document | Description | |----------|-------------| -| [Methodology](docs/methodology.md) | Full EDPA v2.21.0 specification | +| [Methodology](docs/methodology.md) | Full EDPA v2.22.0 specification | | [Quick Start](docs/quick-start.md) | 10-minute setup guide | | [Operational Runbook](docs/RUNBOOK.md) | Every `/edpa:*` command end to end — setup, close-iteration, capacity, autocalib, board | | [Playbook](docs/playbook.md) | From empty repo to first closed PI — full operations guide (Czech) | diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 7f54ecd..928356d 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -95,7 +95,7 @@ engine + `.edpa/` tree. **Expected output (last steps):** ``` - [1] Vendor engine ✓ Vendored engine → .edpa/engine/ (49 scripts, VERSION 2.21.0) + [1] Vendor engine ✓ Vendored engine → .edpa/engine/ (49 scripts, VERSION 2.22.0) [2] Directory tree ✓ Directory tree at .edpa/ [3] Config templates ✓ Seeded people.yaml, edpa.yaml, cw_heuristics.yaml [4] ID counter ✓ id_counters.yaml seeded @@ -289,10 +289,17 @@ event kinds together, all read from materialized `evidence[]`: changes are materialized as `state_transition` signals (weight 0 — analytics + delivery lead-time, never scored directly); the engine reconstructs gate events from them with - `effective_js = parent.JS × gate_weights[type][transition]`. - Validated against `edpa-simulation-gates` harness (avg MAD 7.8%, - stable to ±20% CW perturbation). **Requires the transitions to be - materialized** — the post-commit hook and `--materialize` write them. + `effective_js = parent.JS × (credited gate weight)`. Gate credit is a + **per-lifecycle budget** (D-81): each ladder edge credits at most once + across the item's whole history (QA bounces / reopens earn no second + credit; a forward jump credits the bypassed edges' sum), so total gate + credit per item stays ≤ 1.0 × JS. Contributors follow the D-82 + attribution chain — in-window evidence shares (`window`) → all-time + contributors[] (`passthrough`) → transition author (`author`), recorded + per event in the gate_events audit. Validated against + `edpa-simulation-gates` harness (avg MAD 7.8%, stable to ±20% CW + perturbation). **Requires the transitions to be materialized** — the + post-commit hook and `--materialize` write them. - **YAML-edit signals** — every commit on a backlog YAML in the iteration window contributes structural signals (create, block_add, list_grow, scalar_change, lines_volume, revert). D-26: materialized diff --git a/docs/mcp.md b/docs/mcp.md index 42709ea..a84822d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5,7 +5,7 @@ read + write access to `.edpa/` project data — config, iterations, people, backlog — over the standard MCP `stdio` transport. Any MCP-aware client (Claude Code, Cursor, Codex CLI, custom Python/TS clients) can use it. -**Production-ready since v1.3.0-beta; current as of v2.21.0** (read + write +**Production-ready since v1.3.0-beta; current as of v2.22.0** (read + write tools — see the tool tables below). Validated handlers, schema-checked inputs, item-ID path-traversal guard, stderr logging, version-aware identity. diff --git a/docs/methodology.md b/docs/methodology.md index 84c38e6..b3fd1a1 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -2,7 +2,7 @@ *Capacity derivation from delivery evidence* -**Version 2.21.0 — July 2026 — Jaroslav Urbanek, Lead Architect** +**Version 2.22.0 — July 2026 — Jaroslav Urbanek, Lead Architect** --- @@ -253,12 +253,20 @@ the person's capacity across whichever items they touched: `level == "Story"` filter.) 2. **Parent gate transitions** — Feature/Epic/Initiative status transitions become synthetic events with effective JS = - `parent.JS × gate_weights[type][transition]`. D-26: the engine reads + `parent.JS × (credited gate weight)`. D-26: the engine reads these from the materialized `state_transition` signals in `evidence[]` (written by the post-commit hook / `/edpa:materialize`) — it no longer - scans git. Parent contributors inherit cw from the parent's - contributors[]; when the parent has none, the transition's recorded - author is credited at cw=1.0. + scans git. **D-81 (gate budget):** the `gate_weights` table is a + per-lifecycle budget (edges sum to 1.0 × parent.JS), replayed over the + item's full transition history — each ladder edge credits at most once + (re-crossings after a QA bounce earn nothing), backward moves earn + nothing, and a forward jump credits the sum of the bypassed edges. + **D-82 (attribution chain):** each gate event's contributors come from + (a) shares recomputed from the parent's *in-window* evidence + (`attribution: window`), else (b) the parent's all-time contributors[] + (`passthrough`), else (c) the transition's recorded author at cw=1.0 + (`author`). The chosen path is recorded per event in + `edpa_results.json` gate_events audit. 3. **YAML-edit signals** — every commit on a backlog YAML contributes structural signals (create / block_add / list_grow / scalar_change / lines_volume / revert). D-26: these are materialized into `evidence[]` diff --git a/docs/playbook.md b/docs/playbook.md index ebf848f..54746fc 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -4,8 +4,8 @@ Kompletni prirucka pro nasazeni metodiky EDPA (Evidence-Driven Proportional Allo EDPA V2 je **local-first**: zdrojem pravdy je `.edpa/backlog/**/*.md` (YAML frontmatter), git je audit trail. GitHub je **volitelny** -- zadny GitHub Project, zadne org Issue Types, zadny obousmerny sync. -**Verze:** EDPA 2.21.0 -**Posledni aktualizace:** 2026-07-20 +**Verze:** EDPA 2.22.0 +**Posledni aktualizace:** 2026-07-21 --- @@ -207,7 +207,7 @@ project: governance: # Auto-razitkovano na verzi pluginu instalatorem. - methodology: "EDPA 2.21.0" + methodology: "EDPA 2.22.0" # Jedina vypocetni cesta od v1.14 (zadny simple/full/gates mode selector, # zadny audit_mode -- snapshoty vzdy nesou plny signals[] audit trail). @@ -245,7 +245,7 @@ EDPA V2 je **evidence-driven**: `cw[osoba, item] = contribution_score / Σ contr | `pr_reviewer` | 2.17 | Odeslany PR review (mimo self) | | `issue_comment` | 1.46 | Komentar na issue/PR (mimo boty) | -Rolove vahy prispevatelu (`--contributor PERSON:ROLE:CW`) -- owner 1.0 / key 0.6 / reviewer 0.25 / consulted 0.15; `evidence_threshold` 1.0. `cw_heuristics.yaml` navic obsahuje `gate_weights` pro Feature/Epic/Initiative -- status transition na rodici rozdeluje jeho Job Size napric lifecyclem (souc = 1.0 per typ). +Rolove vahy prispevatelu (`--contributor PERSON:ROLE:CW`) -- owner 1.0 / key 0.6 / reviewer 0.25 / consulted 0.15; `evidence_threshold` 1.0. `cw_heuristics.yaml` navic obsahuje `gate_weights` pro Feature/Epic/Initiative -- status transition na rodici rozdeluje jeho Job Size napric lifecyclem (souc = 1.0 per typ). Gate kredit je **per-lifecycle budget** (v2.22): kazda ladder hrana kredituje max jednou -- re-crossing po QA bounci ani backward pohyb nic nedostane, forward jump secte preskocene hrany (soucet <= 1.0 x JS per item). Atribuce gate eventu preferuje evidenci z dane iterace: `window` > `passthrough` (all-time contributors[]) > `author` (cw=1.0); zvolena cesta je zaznamenana v auditu (pole `attribution`). ### 1.5 Nakonfigurovat iterace (iterations/) @@ -1015,7 +1015,7 @@ Od v1.14 jedina vypocetni cesta (zadny simple/full/gates mode). Pro kazdou osobu 3. Pro kazdou osobu: `ratio_i = score_i / sum(scores)` 4. Odvozene hodiny: `hours_i = ratio_i x capacity` -Invariant: `sum(DerivedHours) = capacity` (presne, ne priblizne). Feature/Epic/Initiative status transitions navic rozdeluji rodicovsky Job Size pres `gate_weights`. +Invariant: `sum(DerivedHours) = capacity` (presne, ne priblizne). Feature/Epic/Initiative status transitions navic rozdeluji rodicovsky Job Size pres `gate_weights` -- jako per-lifecycle budget (hrana max 1x, backward/re-crossing 0, forward jump = preskocene hrany) s atribuci `window` > `passthrough` > `author`. --- @@ -1171,6 +1171,6 @@ PR-thread signaly (`pr_reviewer`, `issue_comment`) se do `evidence[]` dostanou j | **yaml_edit** | Strukturalni signal z editu backlog YAMLu; nese `delta` (blocks/list-items/scalars/lines) + `raw_weight`/`discount`. Backfill/migrace a commity nad `bulk_item_threshold` (5) itemu maji discount x0.1. | | **MAD** | Mean Absolute Deviation -- prumerna absolutni odchylka (metrika kalibrace) | | **Evidence** | `evidence[]` na itemu -- **jediny zapisovany zdroj signalu** (D-26); `contributors[]`/`cw` jsou odvozene (`aggregate_signals`, preskakuje vahu 0). Zapisuje post-commit hook / `--materialize`; engine je **cisty ctenar**. | -| **Gate** | Status transition na Feature/Epic/Initiative; rozdeluje rodicovsky JS pres `gate_weights` | +| **Gate** | Status transition na Feature/Epic/Initiative; rozdeluje rodicovsky JS pres `gate_weights` -- per-lifecycle budget (hrana max 1x, soucet <= 1.0 x JS), atribuce `window` > `passthrough` > `author` | | **EDPA_NO_LOCAL_EVIDENCE** | Env vlajka -- vypina **jen** automaticky post-commit hook; `local_evidence.py --materialize` ji ignoruje (explicitni catch-up). | | **Ground truth** | Potvrzena realita od tymu (pro kalibraci) | diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 34a1e00..956b72a 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "edpa", - "version": "2.21.0", + "version": "2.22.0", "description": "EDPA — Evidence-Driven Proportional Allocation. Derive hours from local git evidence (commits, yaml edits, status transitions). Zero timesheets, mathematical guarantee, Monte Carlo calibrated CW weights. Local-first: .edpa/backlog/ YAML as source of truth, git as the audit trail. GitHub Projects sync optional.", "author": { "name": "TECHNOMATON", diff --git a/plugin/edpa/scripts/detect_contributors.py b/plugin/edpa/scripts/detect_contributors.py index 6c533cf..8148335 100644 --- a/plugin/edpa/scripts/detect_contributors.py +++ b/plugin/edpa/scripts/detect_contributors.py @@ -20,9 +20,17 @@ Aggregation: contribution_score[P, item] = Σ signal_weights for person P on item - cw[P, item] = score[P, item] / Σ_persons score[*, item] + cw[P, item] = clamp(score[P, item], 0) / Σ_persons clamp(score[*, item], 0) → Σ_persons cw[*, item] = 1.0 (per-item invariant) + Net-negative person scores (revert-only activity, e.g. grooming cleanup + via yaml_edit:revert) clamp to 0 for the cw computation: a negative + share has no meaning in per-item normalization, and letting it through + both inflates the other contributors above cw 1.0 (which the engine + hard-drops as out of range — silently voiding their credit) and breaks + the Σ cw = 1.0 invariant the snapshot audit relies on. The raw score + stays in contribution_score for the audit trail. + Modes: 1. CI mode (env-driven, used by edpa-contributor-detect.yml): PR_NUMBER, PR_AUTHOR, PR_TITLE, PR_BRANCH set by workflow @@ -507,7 +515,6 @@ def aggregate_signals(signals: list[dict], # Resolve every signal's login → person_id, normalising case. by_person: dict[str, list[dict]] = defaultdict(list) - total_score = 0.0 unknown: set[str] = set() for sig in signals: # Zero-weight signals (e.g. state_transition) are analytics-only @@ -524,7 +531,6 @@ def aggregate_signals(signals: list[dict], # `login` field, add resolved person id later in contributor entry. clean = {k: v for k, v in sig.items() if k != "login"} by_person[person_id].append(clean) - total_score += sig["weight"] # Surface tokens that resolved to neither a known github handle nor a # person id — these are credited as-is and the engine awards 0h, so a # silent typo would otherwise vanish without a trace. @@ -534,19 +540,40 @@ def aggregate_signals(signals: list[dict], f"unless it is a real person id; typo or external contributor?).", file=sys.stderr) - if total_score <= 0: - return None - - # Sort persons by contribution_score desc for deterministic YAML order. - contributors: list[dict] = [] + # Per-person net scores. Negative-weight signals (yaml_edit:revert) + # reduce the score of the person who made them — anti-work semantics. + # A person whose signals net NEGATIVE overall is clamped to 0 for the + # cw share: without the clamp their negative score deflates the + # denominator, pushing the other contributors above cw 1.0 — and the + # engine hard-drops out-of-range cw (extract_contributors requires + # 0 <= cw <= 1), silently voiding the legitimate contributor's credit + # on the item. Clamping keeps every cw inside [0, 1] and Σ cw = 1.0. person_scores = [ (pid, sum(s["weight"] for s in sigs), sigs) for pid, sigs in by_person.items() ] + clamped: dict[str, float] = {} + clamped_total = 0.0 + for pid, raw, _sigs in person_scores: + if raw < 0: + print( + f"WARNING: {pid}: net contribution score {raw:.2f} is " + f"negative (revert-only activity?) — clamped to 0 for the " + f"cw share; raw score kept in contribution_score for audit.", + file=sys.stderr, + ) + clamped[pid] = max(0.0, raw) + clamped_total += clamped[pid] + + if clamped_total <= 0: + return None + + # Sort persons by contribution_score desc for deterministic YAML order. + contributors: list[dict] = [] person_scores.sort(key=lambda x: (-x[1], x[0])) for pid, score, sigs in person_scores: - cw = score / total_score + cw = clamped[pid] / clamped_total # Sort signals deterministic by (type, ref) so two detect runs on # identical GH state produce byte-identical YAML. sigs_sorted = sorted(sigs, key=lambda s: (s["type"], s["ref"])) diff --git a/plugin/edpa/scripts/engine.py b/plugin/edpa/scripts/engine.py index a88887d..65b9261 100644 --- a/plugin/edpa/scripts/engine.py +++ b/plugin/edpa/scripts/engine.py @@ -98,6 +98,17 @@ def extract_contributors(item): except (TypeError, ValueError): continue if not 0 <= cw_val <= 1: + # Out-of-range cw used to be dropped SILENTLY — the person lost + # all credit on the item with no trace (reachable via net- + # negative revert math before the aggregate_signals clamp, or + # via hand-written frontmatter). Keep the drop (a share outside + # [0,1] is incoherent) but surface it loudly. + print( + f"WARN: {item.get('id', '?')}: contributors[] entry for " + f"{person!r} has cw={cw_val} outside [0,1] — entry skipped. " + f"Re-run detect_contributors to recompute.", + file=sys.stderr, + ) continue out.append({ "person": person, @@ -720,6 +731,45 @@ def _yaml_edit_from_evidence(edpa_root, start=None, end=None): return out +def _gate_credit_signals_from_evidence(edpa_root, start=None, end=None): + """Read windowed, credit-bearing signals from parent items' evidence[] + for gate-event attribution (D-82). + + Like _yaml_edit_from_evidence but across every signal type that + represents attributable work on the parent (yaml_edit, commit_author, + manual:*). Excluded: + - zero-weight signals (state_transition is analytics-only by design) + - out_of_iteration-tagged signals (D-62 neutralized: audit-visible, + non-crediting) + - agent_contribution (person is _claude — not a people.yaml member; + its share would evaporate in run_edpa and dilute the real shares) + + Returns {item_id: [signal, ...]} for Feature/Epic/Initiative items. + """ + edpa_root = Path(edpa_root) + out: dict[str, list] = {} + for sub in GATE_TYPE_DIRS.values(): + dir_path = edpa_root / "backlog" / sub + if not dir_path.is_dir(): + continue + for f in sorted(dir_path.glob("*.md")): + data = load_yaml(f) or {} + item_id = data.get("id") + if not item_id: + continue + for s in _read_evidence(data): + if not s.get("weight"): + continue + if s.get("type") == "agent_contribution": + continue + if "out_of_iteration" in (s.get("tags") or []): + continue + if not _in_window(s.get("at"), start, end): + continue + out.setdefault(item_id, []).append(s) + return out + + def _activity_contributors(sigs): """D-73: contributor shares for a story-activity event, scoped to the signals actually in THIS iteration's window. @@ -876,20 +926,89 @@ def load_story_activity_events(edpa_root, iteration_id, heuristics, return events, audit +def _build_gate_ladder(weights): + """Parse an ordered ``gate_weights`` table into a status ladder. + + Returns ``(ladder, edge_weights, standalone)``: + - ``ladder[i] -> ladder[i+1]`` carries weight ``edge_weights[i]`` + - ``standalone`` maps ``"A→B" -> weight`` for table entries that did + not fit the walked chain (credited independently, deduped per item) + + The table is authored ladder-ordered (Funnel→…→Done); the walk starts + at the first pair and follows each ``to`` into the next ``from``. + """ + pairs = [] + for key, w in (weights or {}).items(): + a, sep, b = str(key).partition("→") + if not sep: + a, sep, b = str(key).partition("->") + if not sep: + continue + pairs.append((a.strip(), b.strip(), float(w))) + if not pairs: + return [], [], {} + ladder = [pairs[0][0], pairs[0][1]] + edge_weights = [pairs[0][2]] + used = {0} + progressed = True + while progressed: + progressed = False + for i, (a, _b, _w) in enumerate(pairs): + if i not in used and a == ladder[-1]: + ladder.append(pairs[i][1]) + edge_weights.append(pairs[i][2]) + used.add(i) + progressed = True + standalone = { + f"{a}→{b}": w for i, (a, b, w) in enumerate(pairs) if i not in used + } + return ladder, edge_weights, standalone + + def load_gate_events(edpa_root, iteration_id, heuristics, people=None): """Convert status transitions into scoring 'events' for mode=gates. - For Feature/Epic/Initiative parents, every status transition that occurred - within iteration_id's date window becomes an item-shaped event with - job_size = parent.js * gate_weights[type][transition]. Each event reuses + For Feature/Epic/Initiative parents, status transitions within + iteration_id's date window become item-shaped events with + job_size = parent.js × (credited gate weight). Each event reuses its parent's contributor list as evidence, so run_edpa() scores it with the same math as a Done item. - v1.17.1 fix (Finding #1): when the parent has no contributors[] (typical - for IP-iter strategic items seeded with title+js but no team yet), fall - back to crediting the transition's commit author (`changed_by`) at cw=1.0. - Without this fallback, IP iterations with real strategic work derive 0h - because gate events inherit empty contributor lists. + D-81: gate credit is a BUDGET, not per-transition cash. The + gate_weights table splits parent.js across the status ladder (weights + sum to 1.0 by config contract — test_template_gate_weights_sum_to_one): + + * each ladder edge is credited AT MOST ONCE per item, the first time + any recorded transition crosses it — a QA bounce that re-crosses + Implementing→Validating earns no second 0.50 × js; + * backward moves (reopens, e.g. Validating→Implementing) earn + nothing — the rework they represent is credited through the + Story / commit_author / yaml_edit channels; + * a forward JUMP over skipped statuses credits the sum of the + bypassed edges (the work happened, compressed); + * transitions whose statuses don't resolve on the ladder earn + nothing (WARN) — the pre-D-81 equal-split fallback credited an + arbitrary 1/N of parent.js, which could out-weight real gates. + + The budget replays over ALL materialized transitions chronologically + (not just this iteration's window), so an edge credited in an earlier + iteration is never re-credited here. Per item this bounds total gate + credit at 1.0 × parent.js, as the config contract states. + + D-82: contributor attribution follows a chain, recorded per event in + the audit's ``attribution`` field: + + 1. ``window`` — shares recomputed from the parent's IN-WINDOW + credit-bearing evidence (yaml_edit / commit_author / manual), + the same math as _activity_contributors (D-73). Credits whoever + worked the parent in THIS iteration, not whoever touched it + across all time (cross-iteration ghost credit). + 2. ``passthrough`` — the parent's all-time contributors[], when no + in-window credit signals exist. + 3. ``author`` — the transition's commit author (`changed_by`) at + cw=1.0, when the parent has no contributors[] either (v1.17.1 + Finding #1: IP-iter strategic items seeded with title+js but no + team yet would otherwise derive 0h). Stories are NOT emitted here — they continue to flow through load_backlog_items() with the Done filter. @@ -918,12 +1037,20 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): # D-26: read materialized state_transition signals from evidence[] — the # engine is a pure reader and no longer scans git (detect_transitions). The # post-commit hook / `--materialize` persist transitions; we read the snapshot. - transitions = _transitions_from_evidence(edpa_root, start, end) + # D-81: UNWINDOWED read — the full chronological history is needed to + # replay each item's gate-edge budget (edges credited in earlier + # iterations must stay spent); in-window filtering happens per transition + # below via _in_window. + all_transitions = _transitions_from_evidence(edpa_root) gate_weights = (heuristics or {}).get("gate_weights", {}) or {} + ladders = {t: _build_gate_ladder(w) for t, w in gate_weights.items()} + spent: dict[str, dict] = {} # item_id -> {"edges": set[int], "keys": set[str]} + # D-82: windowed credit-bearing parent evidence for the attribution chain. + windowed_credit = _gate_credit_signals_from_evidence(edpa_root, start, end) events = [] audit = [] - for t in transitions: + for t in all_transitions: item_type = t["item_type"] # Stories are surfaced by transitions.py for audit/debug visibility, # but engine gates mode credits stories only at status=Done (handled @@ -931,17 +1058,80 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): # don't double-count. if item_type == "Story": continue - weights = gate_weights.get(item_type, {}) or {} - gate_key = f"{t['from_status']}→{t['to_status']}" - weight = weights.get(gate_key) - if weight is None and weights: - weight = round(1.0 / len(weights), 4) - print( - f"WARN: no gate_weight for {item_type} '{gate_key}', " - f"using equal-split fallback {weight}", - file=sys.stderr, - ) - if not weight or weight <= 0: + if item_type not in ladders: + continue + ladder, edge_weights, standalone = ladders[item_type] + if not ladder: + continue + pos = {s: i for i, s in enumerate(ladder)} + frm, to = t.get("from_status"), t.get("to_status") + if not frm: + # Creation (init → first status): analytics-only. The authoring + # work is already credited by the item's yaml_edit:create signal. + continue + gate_key = f"{frm}→{to}" + item_spent = spent.setdefault(t["item_id"], {"edges": set(), "keys": set()}) + in_window = _in_window(t.get("changed_at"), start, end) + + # Classify the transition against the ladder. + crossed: list[int] = [] # ladder edge indices this transition crosses + stand_key = None # standalone (off-chain) edge key + backward = False + if frm in pos and to in pos: + if pos[to] > pos[frm]: + crossed = list(range(pos[frm], pos[to])) + else: + backward = True # reopen / self-move: no lifecycle progress + elif gate_key in standalone: + stand_key = gate_key + + if backward or (not crossed and stand_key is None): + if in_window: + if backward: + print( + f"note: {t['item_id']} '{gate_key}' moves backward on " + f"the {item_type} ladder - no gate credit " + f"(reopen; rework credited via Story/commit/yaml_edit)", + file=sys.stderr, + ) + else: + print( + f"WARN: {t['item_id']}: no gate_weight for {item_type} " + f"'{gate_key}' and it does not resolve on the status " + f"ladder - no credit. Add an explicit edge to " + f"gate_weights to credit it.", + file=sys.stderr, + ) + continue + + # Spend budget (always, even out-of-window — replay) and compute the + # freshly credited weight (in-window, unspent edges only). + weight = 0.0 + if crossed: + fresh = [i for i in crossed if i not in item_spent["edges"]] + item_spent["edges"].update(crossed) + if in_window: + if fresh: + weight = round(sum(edge_weights[i] for i in fresh), 6) + else: + print( + f"note: {t['item_id']} '{gate_key}' already credited by " + f"an earlier crossing - no double credit", + file=sys.stderr, + ) + else: + fresh_key = stand_key not in item_spent["keys"] + item_spent["keys"].add(stand_key) + if in_window: + if fresh_key: + weight = standalone[stand_key] + else: + print( + f"note: {t['item_id']} '{gate_key}' already credited by " + f"an earlier crossing - no double credit", + file=sys.stderr, + ) + if not in_window or weight <= 0: continue sub = GATE_TYPE_DIRS.get(item_type) @@ -966,7 +1156,19 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): effective_js = round(parent_js * weight, 6) synth_id = f"{t['item_id']}@{t['from_status'] or 'init'}->{t['to_status']}" - contribs = _passthrough_contributors(parent) + # D-82 attribution chain — credit whoever worked the parent in THIS + # iteration first (shares recomputed from its in-window evidence, + # the same math as _activity_contributors / D-73), then the all-time + # contributors[] passthrough, then the transition author (v1.17.1). + # Pre-D-82 the passthrough ran first, so a contributor from an + # earlier iteration kept scoring at gates long after they stopped + # working the item (cross-iteration misattribution). + contribs = _activity_contributors( + windowed_credit.get(t["item_id"]) or []) + attribution = "window" + if not contribs: + contribs = _passthrough_contributors(parent) + attribution = "passthrough" if not contribs: # v1.17.1 fallback: parent has no contributors[] yet → credit the # transition's git author. Resolves email/login via people.yaml. @@ -977,6 +1179,7 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): if not resolved and "@" in changed_by: resolved = resolver.get(changed_by.split("@", 1)[0]) if resolved: + attribution = "author" contribs = [{ "person": resolved, "cw": 1.0, @@ -990,6 +1193,8 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): "detected_at": t.get("changed_at"), }], }] + else: + attribution = None events.append({ "id": synth_id, @@ -1007,6 +1212,11 @@ def load_gate_events(edpa_root, iteration_id, heuristics, people=None): "changed_at": t["changed_at"], "changed_by": t["changed_by"], "commit_hash": t["commit_hash"], + # D-82: which attribution path produced the contributors — + # "window" (in-iteration evidence shares), "passthrough" + # (all-time contributors[]), "author" (transition author), or + # None when nobody could be credited. + "attribution": attribution, }) return events, audit diff --git a/plugin/edpa/templates/cw_heuristics.yaml.tmpl b/plugin/edpa/templates/cw_heuristics.yaml.tmpl index c523311..207f8fe 100644 --- a/plugin/edpa/templates/cw_heuristics.yaml.tmpl +++ b/plugin/edpa/templates/cw_heuristics.yaml.tmpl @@ -48,8 +48,12 @@ signals: # Weights split the parent's Job Size across its lifecycle, so prep # work (LBC, decomposition, validation) is credited as it happens — # not only at terminal Done. Weights MUST sum to 1.0 per item type. -# The gate event inherits the parent's contributors[] verbatim -# (per-item shares are unchanged by the gate split). +# D-81: the table is a per-lifecycle BUDGET — each edge credits at most +# once per item (QA bounces / reopens earn no second credit; a forward +# jump credits the bypassed edges' sum), so total gate credit per item +# stays <= 1.0 x JS. D-82: gate contributors follow the attribution +# chain — in-window evidence shares, else the parent's contributors[], +# else the transition author. # # Stories stay Done-only and flow through the regular backlog scan; # they are never emitted as gate events. diff --git a/plugin/edpa/templates/edpa.yaml.tmpl b/plugin/edpa/templates/edpa.yaml.tmpl index 819047c..e677227 100644 --- a/plugin/edpa/templates/edpa.yaml.tmpl +++ b/plugin/edpa/templates/edpa.yaml.tmpl @@ -64,7 +64,7 @@ project: governance: # Auto-stamped to the installed plugin version by install.sh / project_setup.py. - methodology: "EDPA 2.21.0" + methodology: "EDPA 2.22.0" # v1.14: single calculation path. v1.17: yaml_edit signals — every # commit on .edpa/backlog//.md contributes structural credit # (create / block_add / list_grow / scalar_change / lines_volume / diff --git a/tests/test_detect_contributors.py b/tests/test_detect_contributors.py index ca1ed6d..eb6dbe4 100644 --- a/tests/test_detect_contributors.py +++ b/tests/test_detect_contributors.py @@ -217,6 +217,62 @@ def test_aggregate_zero_signals_returns_none(): assert dc.aggregate_signals([], {}) is None +def test_aggregate_negative_score_person_clamped_to_zero(capsys): + """A person whose signals net NEGATIVE (e.g. only a yaml_edit:revert + from grooming cleanup on somebody else's item) clamps to cw 0. + + Pre-fix the negative score deflated the denominator, pushing the + legitimate contributor above cw 1.0 — and the engine hard-drops + out-of-range cw (extract_contributors requires 0 <= cw <= 1), so the + real owner silently lost ALL credit on the item. + """ + sigs = [ + {"type": "commit_author", "ref": "commit/aaa", "login": "alice", + "weight": 4.0, "detected_at": "t"}, + {"type": "yaml_edit", "ref": "commit/bbb/S-1.md", "login": "bob", + "weight": -0.5, "tags": ["revert(-1)"], "detected_at": "t"}, + ] + result = dc.aggregate_signals(sigs, {"alice": "alice", "bob": "bob"}) + assert result is not None + by_person = {c["person"]: c for c in result} + assert by_person["alice"]["cw"] == 1.0 + assert by_person["bob"]["cw"] == 0.0 + # Raw (negative) score is kept for the audit trail; only the cw share + # is clamped. + assert by_person["bob"]["contribution_score"] == -0.5 + assert sum(c["cw"] for c in result) == pytest.approx(1.0) + assert "clamped to 0" in capsys.readouterr().err + + +def test_aggregate_all_negative_returns_none(): + """Every contributor net-negative → nothing creditable → warn-and-skip + path (None); a negative total must never leak into cw math.""" + sigs = [ + {"type": "yaml_edit", "ref": "commit/bbb/S-1.md", "login": "bob", + "weight": -0.5, "tags": ["revert(-1)"], "detected_at": "t"}, + ] + assert dc.aggregate_signals(sigs, {"bob": "bob"}) is None + + +def test_aggregate_revert_reduces_own_score(): + """A revert signal from the SAME person reduces their own net score — + clamping kicks in only at the per-person net-negative boundary, not + per signal.""" + sigs = [ + {"type": "commit_author", "ref": "commit/aaa", "login": "alice", + "weight": 4.0, "detected_at": "t"}, + {"type": "yaml_edit", "ref": "commit/aab/S-1.md", "login": "alice", + "weight": -0.5, "tags": ["revert(-1)"], "detected_at": "t"}, + {"type": "commit_author", "ref": "commit/bbb", "login": "bob", + "weight": 4.0, "detected_at": "t"}, + ] + result = dc.aggregate_signals(sigs, {"alice": "alice", "bob": "bob"}) + by_person = {c["person"]: c for c in result} + assert by_person["alice"]["contribution_score"] == 3.5 + assert by_person["alice"]["cw"] == pytest.approx(3.5 / 7.5, abs=0.001) + assert by_person["bob"]["cw"] == pytest.approx(4.0 / 7.5, abs=0.001) + + def test_aggregate_resolves_login_via_people_map(): """GitHub login should be mapped to canonical person id.""" sigs = [ diff --git a/tests/test_frontmatter_guards.py b/tests/test_frontmatter_guards.py index 5c7dcdc..20d398e 100644 --- a/tests/test_frontmatter_guards.py +++ b/tests/test_frontmatter_guards.py @@ -173,3 +173,135 @@ def test_gate_event_skips_non_numeric_parent_js(edpa_root: Path, capsys) -> None ) assert events == [] assert "js must be numeric" in capsys.readouterr().err + + +# ─── D-82: gate-event attribution chain (window → passthrough → author) ───── + + +FEATURE_MD_WINDOWED = ( + "---\n" + "id: F-1\ntype: Feature\ntitle: T\nparent: E-1\n" + "js: 8\nstatus: Implementing\niteration: PI-2026-1\n" + "contributors:\n - person: alice\n cw: 1\n" + "evidence:\n" + " - type: state_transition\n" + " from_status: Funnel\n" + " to_status: Implementing\n" + " at: \"2026-04-07T10:00:00+00:00\"\n" + " person: alice\n" + " ref: commit/abc1234\n" + " - type: yaml_edit\n" + " person: bob\n" + " weight: 2.0\n" + " ref: commit/def5678/F-1.md\n" + " at: \"2026-04-07T11:00:00+00:00\"\n" + " - type: yaml_edit\n" + " person: carol\n" + " weight: 1.0\n" + " ref: commit/def9999/F-1.md\n" + " at: \"2026-04-08T11:00:00+00:00\"\n" + "---\n" +) + +FEATURE_MD_NO_CONTRIBUTORS = ( + "---\n" + "id: F-1\ntype: Feature\ntitle: T\nparent: E-1\n" + "js: 8\nstatus: Implementing\niteration: PI-2026-1\n" + "evidence:\n" + " - type: state_transition\n" + " from_status: Funnel\n" + " to_status: Implementing\n" + " at: \"2026-04-07T10:00:00+00:00\"\n" + " person: alice\n" + " ref: commit/abc1234\n" + "---\n" +) + +FEATURE_MD_ONLY_EXCLUDED_SIGNALS = ( + "---\n" + "id: F-1\ntype: Feature\ntitle: T\nparent: E-1\n" + "js: 8\nstatus: Implementing\niteration: PI-2026-1\n" + "contributors:\n - person: alice\n cw: 1\n" + "evidence:\n" + " - type: state_transition\n" + " from_status: Funnel\n" + " to_status: Implementing\n" + " at: \"2026-04-07T10:00:00+00:00\"\n" + " person: alice\n" + " ref: commit/abc1234\n" + " - type: yaml_edit\n" + " person: bob\n" + " weight: 2.0\n" + " ref: commit/old0000/F-1.md\n" + " at: \"2026-03-01T11:00:00+00:00\"\n" + " - type: agent_contribution\n" + " person: _claude\n" + " agent: claude-test\n" + " weight: 1.0\n" + " ref: commit/abc1234/agent/claude-test\n" + " at: \"2026-04-07T10:30:00+00:00\"\n" + "---\n" +) + + +def _plant_feature(edpa_root: Path, content: str) -> None: + (edpa_root / "iterations" / "PI-2026-1.1.yaml").write_text( + ITER_YAML, encoding="utf-8") + (edpa_root / "backlog" / "features" / "F-1.md").write_text( + content, encoding="utf-8") + + +def test_gate_event_prefers_in_window_shares(edpa_root: Path) -> None: + """D-82 chain step 1: gate contributors are recomputed from the parent's + IN-WINDOW evidence (bob 2.0 / carol 1.0), NOT copied from the all-time + contributors[] (alice) — pre-D-82 alice kept scoring at gates in + iterations she never touched the item (cross-iteration ghost credit).""" + _plant_feature(edpa_root, FEATURE_MD_WINDOWED) + events, audit = engine.load_gate_events( + edpa_root, "PI-2026-1.1", GATE_HEUR, + people=[{"id": "alice"}, {"id": "bob"}, {"id": "carol"}], + ) + assert len(events) == 1 + by_person = {c["person"]: c for c in events[0]["contributors"]} + assert set(by_person) == {"bob", "carol"} + assert by_person["bob"]["cw"] == pytest.approx(2 / 3, abs=0.001) + assert by_person["carol"]["cw"] == pytest.approx(1 / 3, abs=0.001) + assert audit[0]["attribution"] == "window" + + +def test_gate_event_passthrough_attribution_without_window_signals( + edpa_root: Path) -> None: + """D-82 chain step 2: no in-window credit signals → the all-time + contributors[] passthrough (pre-D-82 behavior) applies.""" + _plant_feature(edpa_root, FEATURE_MD.format(js="8")) + events, audit = engine.load_gate_events( + edpa_root, "PI-2026-1.1", GATE_HEUR, people=PEOPLE, + ) + assert [c["person"] for c in events[0]["contributors"]] == ["alice"] + assert audit[0]["attribution"] == "passthrough" + + +def test_gate_event_author_attribution_when_no_contributors( + edpa_root: Path) -> None: + """D-82 chain step 3 (v1.17.1): no in-window signals AND no + contributors[] → the transition author is credited at cw=1.0.""" + _plant_feature(edpa_root, FEATURE_MD_NO_CONTRIBUTORS) + events, audit = engine.load_gate_events( + edpa_root, "PI-2026-1.1", GATE_HEUR, people=PEOPLE, + ) + assert [c["person"] for c in events[0]["contributors"]] == ["alice"] + assert events[0]["contributors"][0]["cw"] == 1.0 + assert audit[0]["attribution"] == "author" + + +def test_gate_event_window_ignores_out_of_window_and_agent_signals( + edpa_root: Path) -> None: + """D-82 reader exclusions: out-of-window edits don't count, and + agent_contribution (_claude — not a people.yaml member) never dilutes + human shares. Both excluded here → passthrough to alice.""" + _plant_feature(edpa_root, FEATURE_MD_ONLY_EXCLUDED_SIGNALS) + events, audit = engine.load_gate_events( + edpa_root, "PI-2026-1.1", GATE_HEUR, people=PEOPLE, + ) + assert [c["person"] for c in events[0]["contributors"]] == ["alice"] + assert audit[0]["attribution"] == "passthrough" diff --git a/tests/test_gate_allocation.py b/tests/test_gate_allocation.py index 224c73e..e6a3cd0 100644 --- a/tests/test_gate_allocation.py +++ b/tests/test_gate_allocation.py @@ -300,7 +300,9 @@ def test_template_gate_weights_sum_to_one(): # engine integration (v1.14: single-mode — gates is the only path) # --------------------------------------------------------------------------- -def _run_engine(repo, edpa, iteration="PI-2026-1.1"): +def _run_engine_full(repo, edpa, iteration="PI-2026-1.1"): + """Materialize evidence + run the engine; return (results, process) so + tests can also assert on stderr (WARN / note lines).""" # D-26: the engine is a pure reader of evidence[]. Materialize git # transitions into evidence[] first (exactly as close-iteration would), # so the engine has state_transition signals to build gate events from. @@ -323,7 +325,12 @@ def _run_engine(repo, edpa, iteration="PI-2026-1.1"): cwd=repo, capture_output=True, text=True, encoding="utf-8", ) assert r.returncode == 0, r.stderr - return json.loads(out.read_text()) + return json.loads(out.read_text()), r + + +def _run_engine(repo, edpa, iteration="PI-2026-1.1"): + result, _proc = _run_engine_full(repo, edpa, iteration) + return result def test_engine_produces_gate_events(tmp_path): @@ -378,6 +385,10 @@ def test_no_transitions_degenerates_to_done_credit(tmp_path): def test_status_revert_does_not_subtract(tmp_path): + """A backward move earns nothing and never SUBTRACTS: the first crossing + keeps its credit. The re-crossing after the revert earns no second credit + (D-81 edge budget) — pre-D-81 both forward crossings were credited (2 × + 0.10), inflating the item's gate total beyond the ladder budget.""" repo, edpa = _make_repo(tmp_path) _change_status(repo, edpa, "backlog/features/F-1.md", "Analyzing", "2026-04-08T12:00:00") @@ -388,10 +399,74 @@ def test_status_revert_does_not_subtract(tmp_path): result = _run_engine(repo, edpa) forward = [e for e in result["gate_events"] if e["parent_id"] == "F-1" and e["transition"] == "Funnel→Analyzing"] - assert len(forward) == 2 # both forward transitions credited + assert len(forward) == 1 # first crossing credited; re-crossing deduped + assert forward[0]["weight"] == pytest.approx(0.10) assert result["all_invariants_passed"] +def test_gate_bounce_credits_each_edge_once(tmp_path): + """D-81 core scenario: QA bounce. Validating→Implementing earns nothing + (backward), the re-crossed Implementing→Validating earns nothing (edge + already spent). Total gate credit stays within the ladder budget + (0.10+0.05+0.05+0.50 = 0.70 of 1.0) instead of reaching 1.34.""" + repo, edpa = _make_repo(tmp_path) + f = "backlog/features/F-1.md" + _change_status(repo, edpa, f, "Analyzing", "2026-04-07T12:00:00") + _change_status(repo, edpa, f, "Backlog", "2026-04-08T12:00:00") + _change_status(repo, edpa, f, "Implementing", "2026-04-09T12:00:00") + _change_status(repo, edpa, f, "Validating", "2026-04-10T12:00:00") + _change_status(repo, edpa, f, "Implementing", "2026-04-11T12:00:00") # QA reject + _change_status(repo, edpa, f, "Validating", "2026-04-12T12:00:00") # re-submit + result, proc = _run_engine_full(repo, edpa) + ev = [e for e in result["gate_events"] if e["parent_id"] == "F-1"] + total_w = sum(e["weight"] for e in ev) + assert total_w == pytest.approx(0.10 + 0.05 + 0.05 + 0.50) + impl_val = [e for e in ev if e["transition"] == "Implementing→Validating"] + assert len(impl_val) == 1 + assert "moves backward" in proc.stderr + assert "already credited" in proc.stderr + + +def test_forward_jump_sums_bypassed_edges(tmp_path): + """D-81: Funnel→Backlog in one step credits the bypassed edges + (0.10 + 0.05) — the work happened, compressed. Pre-D-81 the jump hit + the equal-split fallback with an arbitrary 1/7 ≈ 0.143.""" + repo, edpa = _make_repo(tmp_path) + _change_status(repo, edpa, "backlog/features/F-1.md", "Backlog", + "2026-04-08T12:00:00") + result = _run_engine(repo, edpa) + ev = [e for e in result["gate_events"] if e["parent_id"] == "F-1"] + assert len(ev) == 1 + assert ev[0]["transition"] == "Funnel→Backlog" + assert ev[0]["weight"] == pytest.approx(0.15) + + +def test_gate_budget_replays_across_iterations(tmp_path): + """D-81: an edge credited in iteration 1 stays spent in iteration 2 — + a bounce + re-crossing in a LATER iteration earns nothing. The budget + replays over the full materialized history, not just the window.""" + repo, edpa = _make_repo(tmp_path) + (edpa / "iterations" / "PI-2026-1.2.yaml").write_text( + "iteration:\n" + " id: PI-2026-1.2\n pi: PI-2026-1\n status: closed\n" + " start_date: 2026-04-20\n end_date: 2026-05-01\n weeks: 2\n" + ) + f = "backlog/features/F-1.md" + # iter 1: Funnel→Analyzing→Backlog — credited in iteration 1 + _change_status(repo, edpa, f, "Analyzing", "2026-04-08T12:00:00") + _change_status(repo, edpa, f, "Backlog", "2026-04-10T12:00:00") + r1 = _run_engine(repo, edpa, "PI-2026-1.1") + w1 = sum(e["weight"] for e in r1["gate_events"] if e["parent_id"] == "F-1") + assert w1 == pytest.approx(0.15) + + # iter 2: bounce back and re-cross — no NEW credit + _change_status(repo, edpa, f, "Analyzing", "2026-04-22T12:00:00") + _change_status(repo, edpa, f, "Backlog", "2026-04-24T12:00:00") + r2 = _run_engine(repo, edpa, "PI-2026-1.2") + credited = [e for e in r2["gate_events"] if e["parent_id"] == "F-1"] + assert credited == [] + + def test_demo_runs_without_mode_arg(tmp_path): """v1.14: --mode flag was removed. --demo just works (no git history, so gate_events is empty and engine credits Done items declaratively).""" @@ -402,12 +477,15 @@ def test_demo_runs_without_mode_arg(tmp_path): assert r.returncode == 0, r.stderr -def test_unknown_gate_uses_equal_split_fallback(tmp_path): +def test_unknown_gate_earns_no_credit_and_warns(tmp_path): + """D-81: a transition that does not resolve on the status ladder earns + NOTHING (WARN) — the pre-D-81 equal-split fallback credited an arbitrary + 1/N of parent.js (≈0.143 for Features — more than Releasing→Done).""" repo, edpa = _make_repo(tmp_path) _change_status(repo, edpa, "backlog/features/F-1.md", "WeirdStatus", "2026-04-08T12:00:00") - result = _run_engine(repo, edpa) + result, proc = _run_engine_full(repo, edpa) weird = [e for e in result["gate_events"] if e["parent_id"] == "F-1" and "WeirdStatus" in e["transition"]] - assert len(weird) == 1 - assert 0 < weird[0]["weight"] < 1 + assert weird == [] + assert "no gate_weight" in proc.stderr diff --git a/tests/test_invariants.py b/tests/test_invariants.py index f5d15e9..d0c62ad 100644 --- a/tests/test_invariants.py +++ b/tests/test_invariants.py @@ -299,6 +299,27 @@ def test_extract_contributors_skips_invalid_entries(): assert extracted[0]["person"] == "alice" +def test_extract_contributors_warns_on_out_of_range_cw(capsys): + """Out-of-range cw is still dropped (a share outside [0,1] is + incoherent) but never silently — pre-fix this path voided a person's + credit with no trace (net-negative revert math could push the legit + contributor above cw 1.0; also reachable via hand-written frontmatter).""" + item = { + "id": "S-9", + "contributors": [ + {"person": "alice", "cw": 1.14}, # out of range — dropped + WARN + {"person": "carol", "cw": -0.14}, # out of range — dropped + WARN + {"person": "bob", "cw": 0.6}, # ok + ], + } + extracted = extract_contributors(item) + assert [c["person"] for c in extracted] == ["bob"] + err = capsys.readouterr().err + assert "S-9" in err + assert "alice" in err and "carol" in err + assert "outside [0,1]" in err + + def test_extract_contributors_tolerates_legacy_as_field(): """v1.11 engine ignores `as:` for backward read compatibility with fixtures from v1.10-era tests. validate_syntax.py rejects `as:` diff --git a/web/package-lock.json b/web/package-lock.json index 87041e6..817c4c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "edpa-web", - "version": "2.21.0", + "version": "2.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "edpa-web", - "version": "2.21.0", + "version": "2.22.0", "dependencies": { "@astrojs/sitemap": "^3.7.3", "@vercel/analytics": "^2.0.1", diff --git a/web/package.json b/web/package.json index b6a59b5..347a266 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "edpa-web", "type": "module", - "version": "2.21.0", + "version": "2.22.0", "private": true, "scripts": { "dev": "astro dev", diff --git a/web/src/pages/en/playbook.astro b/web/src/pages/en/playbook.astro index 038514a..49285ee 100644 --- a/web/src/pages/en/playbook.astro +++ b/web/src/pages/en/playbook.astro @@ -218,7 +218,7 @@ naming: -

Contributor role weights (--contributor PERSON:ROLE:CW) — owner 1.0 / key 0.6 / reviewer 0.25 / consulted 0.15; evidence_threshold 1.0. cw_heuristics.yaml additionally carries gate_weights for Feature/Epic/Initiative — a status transition on the parent distributes its Job Size across the lifecycle (sum = 1.0 per type).

+

Contributor role weights (--contributor PERSON:ROLE:CW) — owner 1.0 / key 0.6 / reviewer 0.25 / consulted 0.15; evidence_threshold 1.0. cw_heuristics.yaml additionally carries gate_weights for Feature/Epic/Initiative — a status transition on the parent distributes its Job Size across the lifecycle (sum = 1.0 per type). Gate credit is a per-lifecycle budget (v2.22): each ladder edge credits at most once — a re-crossing after a QA bounce or a backward move earns nothing, a forward jump sums the bypassed edges (Σ ≤ 1.0 × JS per item). Gate-event attribution prefers in-iteration evidence: windowpassthrough (all-time contributors) → author (cw=1.0); the chosen path is recorded in the audit.

1.5 Configure iterations (iterations/)

PI and iterations live in .edpa/iterations/ as separate files:

@@ -882,7 +882,7 @@ python3 .edpa/engine/scripts/reports.py PI-2026-1.1
  • Per person: ratio_i = score_i / Σ scores
  • Derived hours: hours_i = ratio_i × capacity
  • -
    Invariant: Σ DerivedHours = capacity per person (exactly, not approximately). Feature/Epic/Initiative status transitions additionally distribute the parent's Job Size via gate_weights.
    +
    Invariant: Σ DerivedHours = capacity per person (exactly, not approximately). Feature/Epic/Initiative status transitions additionally distribute the parent's Job Size via gate_weights — as a per-lifecycle budget (edge at most once, Σ ≤ 1.0 × JS) with attribution windowpassthroughauthor.
    @@ -1009,7 +1009,7 @@ cp .edpa/engine/templates/edpa.yaml.tmpl .edpa/config/edpa.yaml SignalProof of contribution from git/PR (commit_author, pr_reviewer, issue_comment) with a weight MADMean Absolute Deviation (calibration metric) Evidenceevidence[] on an item — aggregated signals; detect_contributors.py computes contributors[] from them - GateStatus transition on a Feature/Epic/Initiative; distributes the parent JS via gate_weights + GateStatus transition on a Feature/Epic/Initiative; distributes the parent JS via gate_weights — per-lifecycle budget (edge at most once, Σ ≤ 1.0 × JS), attribution windowpassthroughauthor Ground truthTeam-confirmed reality (used for calibration) diff --git a/web/src/pages/playbook.astro b/web/src/pages/playbook.astro index a38693d..8a4fc63 100644 --- a/web/src/pages/playbook.astro +++ b/web/src/pages/playbook.astro @@ -219,7 +219,7 @@ naming: -

    Rolové váhy přispěvatelů (--contributor PERSON:ROLE:CW) — owner 1.0 / key 0.6 / reviewer 0.25 / consulted 0.15; evidence_threshold 1.0. cw_heuristics.yaml navíc obsahuje gate_weights pro Feature/Epic/Initiative — status transition na rodiči rozděluje jeho Job Size napříč lifecyclem (součet = 1.0 per typ).

    +

    Rolové váhy přispěvatelů (--contributor PERSON:ROLE:CW) — owner 1.0 / key 0.6 / reviewer 0.25 / consulted 0.15; evidence_threshold 1.0. cw_heuristics.yaml navíc obsahuje gate_weights pro Feature/Epic/Initiative — status transition na rodiči rozděluje jeho Job Size napříč lifecyclem (součet = 1.0 per typ). Gate kredit je per-lifecycle budget (v2.22): každá ladder hrana kredituje max jednou — re-crossing po QA bounci ani backward pohyb nic nedostane, forward jump sečte přeskočené hrany (Σ ≤ 1.0 × JS per item). Atribuce gate eventů preferuje evidenci z dané iterace: windowpassthrough (all-time contributors) → author (cw=1.0); zvolená cesta je zaznamenaná v auditu.

    Kalibrace přes /edpa:calibrate — Monte Carlo random sampling + coordinate descent přes 3D prostor signálových vah proti syntetickému korpusu. Detail viz sekce Architektura.

    @@ -878,7 +878,7 @@ python3 .edpa/engine/scripts/reports.py PI-2026-1.1
  • Pro každou osobu: ratio_i = score_i / sum(scores)
  • Odvozené hodiny: hours_i = ratio_i × capacity
  • -
    Invariant: sum(DerivedHours) = capacity (přesně, ne přibližně). Feature/Epic/Initiative status transitions navíc rozdělují rodičovský Job Size přes gate_weights.
    +
    Invariant: sum(DerivedHours) = capacity (přesně, ne přibližně). Feature/Epic/Initiative status transitions navíc rozdělují rodičovský Job Size přes gate_weights — jako per-lifecycle budget (hrana max 1×, Σ ≤ 1.0 × JS) s atribucí windowpassthroughauthor.
    @@ -1004,7 +1004,7 @@ cp .edpa/engine/templates/edpa.yaml.tmpl .edpa/config/edpa.yaml SignalDoklad příspěvku z gitu/PR (commit_author, pr_reviewer, issue_comment) s váhou MADMean Absolute Deviation — průměrná absolutní odchylka (metrika kalibrace) Evidenceevidence[] na itemu — agregované signály; detect_contributors.py z nich počítá contributors[] - GateStatus transition na Feature/Epic/Initiative; rozděluje rodičovský JS přes gate_weights + GateStatus transition na Feature/Epic/Initiative; rozděluje rodičovský JS přes gate_weights — per-lifecycle budget (hrana max 1×, Σ ≤ 1.0 × JS), atribuce windowpassthroughauthor Ground truthPotvrzená realita od týmu (pro kalibraci)