decision-system S4b: evidence-loop levers (ConfidenceBand.claimed, EvidenceLadder, StopRule, StakeReducer, CostModel) #1262

Closed
opened 2026-10-23 17:10:38 +00:00 by jared · 2 comments
Owner

Problem

The predict loop needs fixed numbers: the band that a claim gets, the next rung of evidence, when to stop, which stake-reducing moves apply, and what each route costs in Jared-minutes. Today ConfidenceBand has only LEVELS and self.of_percent. EvidenceLadder, StopRule, StakeReducer, and CostModel do not exist. The design is in docs/decision-system/levers.md (ConfidenceBand, levers 4 to 6, CostModel). This ticket is the second of three S4 tickets. It uses WrongCost and AnswerSource::Result from S4a.

Approach

All five objects are deterministic. None calls a model.

ConfidenceBand becomes a value with value and citations. It keeps LEVELS = [50, 65, 80, 90] and self.of_percent unchanged, because CalibrationReport calls of_percent. claimed(value, citations) takes the highest level at or below value (50 when none). When citations is empty, it drops one level. The drop stops at 50. reaches?(required) is true when required is nil or when value >= required.

EvidenceLadder::RUNGS holds the five rungs of lever 4:

index action cost_minutes
0 :known_preferences 0.0
1 :store_search 0.2
2 :read_code_and_adrs 1.0
3 :experiment 10.0
4 :ask_jared nil

next_rung(after:, source:) returns RUNGS[after + 1] when that rung exists and its index is at or below source.max_rung. Else it returns nil.

StopRule#stop?(band:, required:, next_rung:, risk_minutes:) returns true in three cases, checked in this order: (1) band.reaches?(required); (2) next_rung is nil; (3) next_rung.cost_minutes.to_f > risk_minutes. Else it returns false. The caller computes risk_minutes as P(wrong) × wrong-cost minutes.

StakeReducer#options(row) returns the moves of MOVES = %i[reversible defer split] for which row.supports?(move) is true, in MOVES order. This order is the O-1 AFK default from docs/decision-system/decisions-register.md: reversible option first, then defer. Never wait.

CostModel holds REWORK_MINUTES = { low: 5, medium: 30, high: 240 }. ping_minutes defaults to 10. This is the O-6 AFK default, marked as a guess. minutes_per_dollar: and predict_dollars: have no default. predict_minutes is predict_dollars * minutes_per_dollar.

Required work:

  1. Extend ConfidenceBand with value, citations, claimed, lower, and reaches?, and keep of_percent — check: present def self.claimed in plugins/os-me/lib/os_me/confidence_band.rb
  2. Add EvidenceLadder with Rung, RUNGS, and next_rung — check: present def next_rung(after:, source:) in plugins/os-me/lib/os_me/evidence_ladder.rb
  3. Add StopRule — check: present def stop?(band:, required:, next_rung:, risk_minutes:) in plugins/os-me/lib/os_me/stop_rule.rb
  4. Add StakeReducer — check: present MOVES = %i[reversible defer split] in plugins/os-me/lib/os_me/stake_reducer.rb
  5. Add CostModel — check: present REWORK_MINUTES = { low: 5, medium: 30, high: 240 } in plugins/os-me/lib/os_me/cost_model.rb

Acceptance criteria

  1. Given the claims (85, ["ADR-0272"]), (95, ["c"]), (65, ["c"]), and (40, ["c"]), when ConfidenceBand.claimed runs, then the band values are 80, 90, 65, and 50, and the first band has the citations ["ADR-0272"].
  2. Given the claims (85, []), (90, []), (65, []), and (50, []), when ConfidenceBand.claimed runs, then the band values are 65, 80, 50, and 50.
  3. Given a band of 80, when reaches? runs with nil, 70, and 85, then it returns true, true, and false. Given a band of 90, when reaches?(85) runs, then it returns true.
  4. Given a facts source (max_rung 2), when EvidenceLadder#next_rung runs with after: 0, 1, and 2, then it returns the rung (1, :store_search, 0.2), the rung (2, :read_code_and_adrs, 1.0), and nil.
  5. Given an experiment source, when next_rung runs with after: 2 and 3, then it returns the rung (3, :experiment, 10.0) and nil. Given a rule source, when next_rung runs with after: 0 and 1, then it returns the rung (1, :store_search, 0.2) and nil. Given a taste source, when next_rung(after: 0) runs, then it returns nil.
  6. Given a band of 80, a required band of 70, the next rung 1, and risk_minutes 100.0, when StopRule#stop? runs, then it returns true (stop reason 1).
  7. Given a band of 65, a required band of 85, no next rung (nil), and risk_minutes 100.0, when StopRule#stop? runs, then it returns true (stop reason 2).
  8. Given a band of 65, a required band of 85, the next rung 3 (10.0 minutes), and risk_minutes 6.0, when StopRule#stop? runs, then it returns true (stop reason 3). Given the same band and the next rung 2 (1.0 minutes) with risk_minutes 84.0 or 1.0, then it returns false.
  9. Given a row that supports :defer and :reversible, when StakeReducer#options runs, then it returns [:reversible, :defer]. Given a row that supports all three moves, then it returns [:reversible, :defer, :split]. Given a row that supports none, then it returns [].
  10. Given CostModel.new(minutes_per_dollar: 4.0, predict_dollars: 0.25), when it runs, then ping_minutes is 10, predict_minutes is 1.0, and wrong_minutes returns 5, 30, and 240 for wrong costs at the levels :low, :medium, and :high. Given ping_minutes: 20, then ping_minutes is 20.

Owner sketch

class OsMe::ConfidenceBand < Data.define(:value, :citations)
  LEVELS = [50, 65, 80, 90].freeze
  def self.claimed(value, citations); end
  def self.of_percent(percent); end
  def lower; end
  def reaches?(required); end
end

class OsMe::EvidenceLadder
  Rung = Data.define(:index, :action, :cost_minutes)
  RUNGS = [Rung.new(0, :known_preferences, 0.0), Rung.new(1, :store_search, 0.2),
           Rung.new(2, :read_code_and_adrs, 1.0), Rung.new(3, :experiment, 10.0),
           Rung.new(4, :ask_jared, nil)].freeze
  def next_rung(after:, source:); end
end

class OsMe::StopRule
  def stop?(band:, required:, next_rung:, risk_minutes:); end
end

class OsMe::StakeReducer
  MOVES = %i[reversible defer split].freeze
  def options(row); end
end

class OsMe::CostModel
  REWORK_MINUTES = { low: 5, medium: 30, high: 240 }.freeze
  def initialize(minutes_per_dollar:, predict_dollars:, ping_minutes: 10); end
  attr_reader :ping_minutes
  def wrong_minutes(wrong_cost); end
  def predict_minutes; end
end

Non-goals

  • RouteSelector, RowRouter, RouteLog, and ReviewOrder (S5).
  • A risk_minutes method. The caller computes it.
  • How a row knows which stake-reducing moves it supports. StakeReducer reads row.supports?(move) only.
  • Parser fields, log fields, and the os-me-route CLI (S4c).
  • The PredictLoop skill text. The orchestrator writes it through the fast path.

Blocked by: #1246 (WrongCost, AnswerSource::Result).

Origin

  • Trigger: User asked for the decision-system build tickets this session. Rewritten 2026-10-23: split #1246 with exact criteria after the #1245 lesson.
  • Improvised this session: none
  • Chain: off-design S3 build ← acceptance criteria too loose ← first ticket draft. Stop at DESIGN (docs/decision-system/levers.md).
  • Root candidate: none found; searched WrongCost, levers, os-me, predict
  • Where: docs/decision-system/build-plan.md S4; docs/decision-system/levers.md ConfidenceBand, levers 4 to 6, CostModel; plugins/os-me/lib/os_me/
  • Session: 4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5.jsonl

VERDICT: CREATE (user-approved stage split of #1246; backlog search only)

## Problem The predict loop needs fixed numbers: the band that a claim gets, the next rung of evidence, when to stop, which stake-reducing moves apply, and what each route costs in Jared-minutes. Today `ConfidenceBand` has only `LEVELS` and `self.of_percent`. `EvidenceLadder`, `StopRule`, `StakeReducer`, and `CostModel` do not exist. The design is in `docs/decision-system/levers.md` (`ConfidenceBand`, levers 4 to 6, `CostModel`). This ticket is the second of three S4 tickets. It uses `WrongCost` and `AnswerSource::Result` from S4a. ## Approach All five objects are deterministic. None calls a model. `ConfidenceBand` becomes a value with `value` and `citations`. It keeps `LEVELS = [50, 65, 80, 90]` and `self.of_percent` unchanged, because `CalibrationReport` calls `of_percent`. `claimed(value, citations)` takes the highest level at or below `value` (50 when none). When `citations` is empty, it drops one level. The drop stops at 50. `reaches?(required)` is true when `required` is `nil` or when `value >= required`. `EvidenceLadder::RUNGS` holds the five rungs of lever 4: | `index` | `action` | `cost_minutes` | |---|---|---| | 0 | `:known_preferences` | 0.0 | | 1 | `:store_search` | 0.2 | | 2 | `:read_code_and_adrs` | 1.0 | | 3 | `:experiment` | 10.0 | | 4 | `:ask_jared` | `nil` | `next_rung(after:, source:)` returns `RUNGS[after + 1]` when that rung exists and its index is at or below `source.max_rung`. Else it returns `nil`. `StopRule#stop?(band:, required:, next_rung:, risk_minutes:)` returns true in three cases, checked in this order: (1) `band.reaches?(required)`; (2) `next_rung` is `nil`; (3) `next_rung.cost_minutes.to_f > risk_minutes`. Else it returns false. The caller computes `risk_minutes` as P(wrong) × wrong-cost minutes. `StakeReducer#options(row)` returns the moves of `MOVES = %i[reversible defer split]` for which `row.supports?(move)` is true, in `MOVES` order. This order is the O-1 AFK default from `docs/decision-system/decisions-register.md`: reversible option first, then defer. Never wait. `CostModel` holds `REWORK_MINUTES = { low: 5, medium: 30, high: 240 }`. `ping_minutes` defaults to 10. This is the O-6 AFK default, marked as a guess. `minutes_per_dollar:` and `predict_dollars:` have no default. `predict_minutes` is `predict_dollars * minutes_per_dollar`. Required work: 1. Extend `ConfidenceBand` with `value`, `citations`, `claimed`, `lower`, and `reaches?`, and keep `of_percent` — check: present `def self.claimed` in `plugins/os-me/lib/os_me/confidence_band.rb` 2. Add `EvidenceLadder` with `Rung`, `RUNGS`, and `next_rung` — check: present `def next_rung(after:, source:)` in `plugins/os-me/lib/os_me/evidence_ladder.rb` 3. Add `StopRule` — check: present `def stop?(band:, required:, next_rung:, risk_minutes:)` in `plugins/os-me/lib/os_me/stop_rule.rb` 4. Add `StakeReducer` — check: present `MOVES = %i[reversible defer split]` in `plugins/os-me/lib/os_me/stake_reducer.rb` 5. Add `CostModel` — check: present `REWORK_MINUTES = { low: 5, medium: 30, high: 240 }` in `plugins/os-me/lib/os_me/cost_model.rb` ## Acceptance criteria 1. Given the claims `(85, ["ADR-0272"])`, `(95, ["c"])`, `(65, ["c"])`, and `(40, ["c"])`, when `ConfidenceBand.claimed` runs, then the band values are 80, 90, 65, and 50, and the first band has the citations `["ADR-0272"]`. 2. Given the claims `(85, [])`, `(90, [])`, `(65, [])`, and `(50, [])`, when `ConfidenceBand.claimed` runs, then the band values are 65, 80, 50, and 50. 3. Given a band of 80, when `reaches?` runs with `nil`, 70, and 85, then it returns true, true, and false. Given a band of 90, when `reaches?(85)` runs, then it returns true. 4. Given a `facts` source (`max_rung` 2), when `EvidenceLadder#next_rung` runs with `after:` 0, 1, and 2, then it returns the rung `(1, :store_search, 0.2)`, the rung `(2, :read_code_and_adrs, 1.0)`, and `nil`. 5. Given an `experiment` source, when `next_rung` runs with `after:` 2 and 3, then it returns the rung `(3, :experiment, 10.0)` and `nil`. Given a `rule` source, when `next_rung` runs with `after:` 0 and 1, then it returns the rung `(1, :store_search, 0.2)` and `nil`. Given a `taste` source, when `next_rung(after: 0)` runs, then it returns `nil`. 6. Given a band of 80, a required band of 70, the next rung 1, and `risk_minutes` 100.0, when `StopRule#stop?` runs, then it returns true (stop reason 1). 7. Given a band of 65, a required band of 85, no next rung (`nil`), and `risk_minutes` 100.0, when `StopRule#stop?` runs, then it returns true (stop reason 2). 8. Given a band of 65, a required band of 85, the next rung 3 (10.0 minutes), and `risk_minutes` 6.0, when `StopRule#stop?` runs, then it returns true (stop reason 3). Given the same band and the next rung 2 (1.0 minutes) with `risk_minutes` 84.0 or 1.0, then it returns false. 9. Given a row that supports `:defer` and `:reversible`, when `StakeReducer#options` runs, then it returns `[:reversible, :defer]`. Given a row that supports all three moves, then it returns `[:reversible, :defer, :split]`. Given a row that supports none, then it returns `[]`. 10. Given `CostModel.new(minutes_per_dollar: 4.0, predict_dollars: 0.25)`, when it runs, then `ping_minutes` is 10, `predict_minutes` is 1.0, and `wrong_minutes` returns 5, 30, and 240 for wrong costs at the levels `:low`, `:medium`, and `:high`. Given `ping_minutes: 20`, then `ping_minutes` is 20. ## Owner sketch ```ruby class OsMe::ConfidenceBand < Data.define(:value, :citations) LEVELS = [50, 65, 80, 90].freeze def self.claimed(value, citations); end def self.of_percent(percent); end def lower; end def reaches?(required); end end class OsMe::EvidenceLadder Rung = Data.define(:index, :action, :cost_minutes) RUNGS = [Rung.new(0, :known_preferences, 0.0), Rung.new(1, :store_search, 0.2), Rung.new(2, :read_code_and_adrs, 1.0), Rung.new(3, :experiment, 10.0), Rung.new(4, :ask_jared, nil)].freeze def next_rung(after:, source:); end end class OsMe::StopRule def stop?(band:, required:, next_rung:, risk_minutes:); end end class OsMe::StakeReducer MOVES = %i[reversible defer split].freeze def options(row); end end class OsMe::CostModel REWORK_MINUTES = { low: 5, medium: 30, high: 240 }.freeze def initialize(minutes_per_dollar:, predict_dollars:, ping_minutes: 10); end attr_reader :ping_minutes def wrong_minutes(wrong_cost); end def predict_minutes; end end ``` ## Non-goals - `RouteSelector`, `RowRouter`, `RouteLog`, and `ReviewOrder` (S5). - A `risk_minutes` method. The caller computes it. - How a row knows which stake-reducing moves it supports. `StakeReducer` reads `row.supports?(move)` only. - Parser fields, log fields, and the `os-me-route` CLI (S4c). - The `PredictLoop` skill text. The orchestrator writes it through the fast path. Blocked by: #1246 (`WrongCost`, `AnswerSource::Result`). ## Origin - Trigger: User asked for the decision-system build tickets this session. Rewritten 2026-10-23: split #1246 with exact criteria after the #1245 lesson. - Improvised this session: none - Chain: off-design S3 build ← acceptance criteria too loose ← first ticket draft. Stop at `DESIGN` (`docs/decision-system/levers.md`). - Root candidate: none found; searched WrongCost, levers, os-me, predict - Where: docs/decision-system/build-plan.md S4; docs/decision-system/levers.md ConfidenceBand, levers 4 to 6, CostModel; plugins/os-me/lib/os_me/ - Session: 4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/4f3f95ea-3c6f-4d23-a0ea-e349d6ab02a5.jsonl VERDICT: CREATE (user-approved stage split of #1246; backlog search only)
Author
Owner

Work started on branch ticket-1262 (/os-sdlc:implement, S4b).

Work started on branch ticket-1262 (/os-sdlc:implement, S4b).
Author
Owner

Resolution

Done: S4b built: ConfidenceBand.claimed, EvidenceLadder, StopRule, StakeReducer, CostModel; implementation 128 complete.

Evidence: Branch ticket-1262, Runner commit; diff-test, lint, suite and required-work gates green; contract audit passed first time; five new test files.

Follow-ups: none

## Resolution **Done:** S4b built: ConfidenceBand.claimed, EvidenceLadder, StopRule, StakeReducer, CostModel; implementation 128 complete. **Evidence:** Branch ticket-1262, Runner commit; diff-test, lint, suite and required-work gates green; contract audit passed first time; five new test files. **Follow-ups:** none
jared closed this issue 2026-10-23 17:27:35 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#1262
No description provided.