Skip to content

Commit 4796344

Browse files
feat: judge tier floor and report-level behavioral attribution
Two trust-boundary fixes for the review judge: - A judge may raise the tier freely, but can no longer fully green-light a deterministic :block: when the heuristic tier is :block and the judge says :auto, Judge.verdict/2 floors the result to :review and records the raw tier in :judge_tier (printed by the mix task). Rendered page content feeds the LLM prompt, so :auto — "nobody looks at this" — is not a downgrade a model may make alone. - Run-level behavioral findings (from --timeline) are no longer appended to every changed view: one unrelated N+1 no longer marks every view :block and double-counts the summary. They stay at the report level — printed in their own section and still failing the run via the exit gate — and reach the LLM judge as prompt context (:run_behavioral) rather than tier arithmetic. The judging orchestration moves from the mix task into Excessibility.Review.judge_changes/2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 75832f3 commit 4796344

6 files changed

Lines changed: 164 additions & 34 deletions

File tree

lib/excessibility/review.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule Excessibility.Review do
2121

2222
alias Excessibility.LiveViewRules
2323
alias Excessibility.Review.Behavioral
24+
alias Excessibility.Review.Judge
2425
alias Excessibility.SnapshotDiff
2526

2627
@type tier :: :auto | :review | :block
@@ -81,6 +82,34 @@ defmodule Excessibility.Review do
8182
%{changes: changes, summary: summarize(changes)}
8283
end
8384

85+
@doc """
86+
Run the configured judge over each change in a report.
87+
88+
Attaches the judge's verdict to every change, replaces the change's
89+
tier with the judged tier, and recomputes the summary. Run-level
90+
behavioral findings are handed to the judge as context
91+
(`:run_behavioral`) but are **not** attributed to any view — they
92+
stay at the report level, where `mix excessibility.review` prints
93+
them and gates the exit code on them. A view's tier only reflects
94+
what that view introduced.
95+
"""
96+
@spec judge_changes(report(), keyword()) :: report()
97+
def judge_changes(report, opts \\ []) do
98+
behavioral = Map.get(report, :behavioral, [])
99+
judge_opts = Keyword.put(opts, :run_behavioral, behavioral)
100+
101+
judged =
102+
Enum.map(report.changes, fn change ->
103+
verdict = Judge.verdict(change, judge_opts)
104+
105+
change
106+
|> Map.put(:verdict, verdict)
107+
|> Map.put(:tier, verdict.tier)
108+
end)
109+
110+
%{report | changes: judged, summary: summarize(judged)}
111+
end
112+
84113
@doc """
85114
Review a single view's baseline-vs-current snapshot pair.
86115
"""

lib/excessibility/review/judge.ex

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ defmodule Excessibility.Review.Judge do
3131
alias Excessibility.Review.Judge.Heuristic
3232

3333
@type verdict :: %{
34-
tier: Excessibility.Review.tier(),
35-
blast_radius: String.t(),
36-
risks: [%{severity: String.t(), area: String.t(), detail: String.t()}],
37-
confidence: float() | nil,
38-
source: :heuristic | :llm
34+
:tier => Excessibility.Review.tier(),
35+
:blast_radius => String.t(),
36+
:risks => [%{severity: String.t(), area: String.t(), detail: String.t()}],
37+
:confidence => float() | nil,
38+
:source => :heuristic | :llm,
39+
optional(:judge_tier) => Excessibility.Review.tier()
3940
}
4041

4142
@callback judge(change :: Excessibility.Review.change(), opts :: keyword()) :: verdict()
@@ -45,13 +46,34 @@ defmodule Excessibility.Review.Judge do
4546
4647
Resolution order: `opts[:judge]`, then `config :excessibility, :review_judge`,
4748
then `Heuristic`.
49+
50+
## Tier floor
51+
52+
A judge may raise the tier freely, but may not fully green-light a
53+
deterministic `:block`: when the change's heuristic tier is `:block`
54+
(new critical/serious findings from the rules engine) and the judge
55+
says `:auto`, the verdict is floored to `:review` and the judge's raw
56+
tier is kept in `:judge_tier`. `:auto` means nobody looks at the
57+
change — rendered page content feeds LLM prompts, so that is not a
58+
downgrade a model is allowed to make on its own. `:review` still cuts
59+
block-level false-positive friction while keeping a human in the loop.
4860
"""
4961
@spec verdict(Excessibility.Review.change(), keyword()) :: verdict()
5062
def verdict(change, opts \\ []) do
5163
judge =
5264
Keyword.get(opts, :judge) ||
5365
Application.get_env(:excessibility, :review_judge, Heuristic)
5466

55-
judge.judge(change, opts)
67+
change
68+
|> judge.judge(opts)
69+
|> apply_tier_floor(change)
5670
end
71+
72+
defp apply_tier_floor(%{tier: :auto} = verdict, %{tier: :block}) do
73+
verdict
74+
|> Map.put(:tier, :review)
75+
|> Map.put(:judge_tier, :auto)
76+
end
77+
78+
defp apply_tier_floor(verdict, _change), do: verdict
5779
end

lib/excessibility/review/judge/llm.ex

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ defmodule Excessibility.Review.Judge.LLM do
4444
end
4545

4646
defp run(change, fun, opts) do
47-
with {:ok, text} when is_binary(text) <- safe_call(fun, build_prompt(change)),
47+
with {:ok, text} when is_binary(text) <- safe_call(fun, build_prompt(change, opts)),
4848
{:ok, verdict} <- parse(text, change) do
4949
verdict
5050
else
@@ -92,7 +92,7 @@ defmodule Excessibility.Review.Judge.LLM do
9292

9393
defp parse_risks(_), do: []
9494

95-
defp build_prompt(change) do
95+
defp build_prompt(change, opts) do
9696
"""
9797
You are a release-risk judge for a Phoenix/LiveView change. Decide whether
9898
this view's change is safe to auto-merge given a strong test + painless
@@ -116,6 +116,9 @@ defmodule Excessibility.Review.Judge.LLM do
116116
117117
Behavioral findings (from telemetry analyzers — queries, state, renders):
118118
#{render_behavioral(Map.get(change, :behavioral, []))}
119+
120+
Run-level behavioral findings (whole test run — context only, not attributed to this view):
121+
#{render_behavioral(Keyword.get(opts, :run_behavioral, []))}
119122
"""
120123
end
121124

lib/mix/tasks/excessibility_review.ex

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ defmodule Mix.Tasks.Excessibility.Review do
3434
use Mix.Task
3535

3636
alias Excessibility.Review
37-
alias Excessibility.Review.Judge
3837

3938
@requirements ["app.config"]
4039

@@ -46,7 +45,7 @@ defmodule Mix.Tasks.Excessibility.Review do
4645
fail_on = parse_fail_on(opts[:fail_on])
4746

4847
report = Review.review(review_opts(opts))
49-
report = if Keyword.get(opts, :judge, false), do: judge_report(report), else: report
48+
report = if Keyword.get(opts, :judge, false), do: Review.judge_changes(report), else: report
5049

5150
print_report(report)
5251
maybe_exit(report, fail_on)
@@ -62,30 +61,6 @@ defmodule Mix.Tasks.Excessibility.Review do
6261
end
6362
end
6463

65-
# Run the configured judge over each change. The run-level behavioral
66-
# findings are attached to every change so the judge weighs behavior
67-
# alongside markup; the verdict's tier overrides the heuristic one.
68-
defp judge_report(report) do
69-
behavioral = Map.get(report, :behavioral, [])
70-
71-
judged =
72-
Enum.map(report.changes, fn change ->
73-
change = Map.update(change, :behavioral, behavioral, &(&1 ++ behavioral))
74-
verdict = Judge.verdict(change)
75-
change |> Map.put(:verdict, verdict) |> Map.put(:tier, verdict.tier)
76-
end)
77-
78-
counts = Enum.frequencies_by(judged, & &1.tier)
79-
80-
summary = %{
81-
auto: Map.get(counts, :auto, 0),
82-
review: Map.get(counts, :review, 0),
83-
block: Map.get(counts, :block, 0)
84-
}
85-
86-
%{changes: judged, behavioral: behavioral, summary: summary}
87-
end
88-
8964
defp parse_fail_on(nil), do: :block
9065
defp parse_fail_on("block"), do: :block
9166
defp parse_fail_on("review"), do: :review
@@ -149,6 +124,13 @@ defmodule Mix.Tasks.Excessibility.Review do
149124
Enum.each(verdict.risks, fn risk ->
150125
Mix.shell().info(" - [#{risk.severity}] #{risk.area}: #{risk.detail}")
151126
end)
127+
128+
if judge_tier = Map.get(verdict, :judge_tier) do
129+
Mix.shell().info(
130+
" (judge said #{judge_tier}; floored to #{verdict.tier} — " <>
131+
"a judge cannot fully green-light new serious findings)"
132+
)
133+
end
152134
end
153135

154136
defp tier_rank(:block), do: 0

test/review/judge_test.exs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,69 @@ defmodule Excessibility.Review.JudgeTest do
133133
:heuristic
134134
end
135135
end
136+
137+
describe "verdict/2 tier floor" do
138+
defp auto_change do
139+
Review.review_pair("home", "<div>hi</div>", "<div>hi</div>")
140+
end
141+
142+
defp completion_replying(tier) do
143+
fn _prompt ->
144+
{:ok, ~s({"tier":"#{tier}","blast_radius":"model says so","risks":[],"confidence":0.9})}
145+
end
146+
end
147+
148+
test "floors a model's :auto verdict to :review when the heuristic says :block" do
149+
verdict = Judge.verdict(block_change(), judge: LLM, completion: completion_replying("auto"))
150+
151+
assert verdict.tier == :review
152+
assert verdict.judge_tier == :auto
153+
assert verdict.source == :llm
154+
end
155+
156+
test "allows a model to downgrade :block to :review" do
157+
verdict = Judge.verdict(block_change(), judge: LLM, completion: completion_replying("review"))
158+
159+
assert verdict.tier == :review
160+
refute Map.has_key?(verdict, :judge_tier)
161+
end
162+
163+
test "allows a judge to raise the tier without restriction" do
164+
verdict = Judge.verdict(auto_change(), judge: LLM, completion: completion_replying("block"))
165+
166+
assert verdict.tier == :block
167+
end
168+
169+
test "a model's :auto verdict on an :auto change is untouched" do
170+
verdict = Judge.verdict(auto_change(), judge: LLM, completion: completion_replying("auto"))
171+
172+
assert verdict.tier == :auto
173+
refute Map.has_key?(verdict, :judge_tier)
174+
end
175+
end
176+
177+
describe "run-level behavioral context" do
178+
test "run_behavioral findings are rendered into the LLM prompt as context" do
179+
completion = fn prompt ->
180+
send(self(), {:prompt, prompt})
181+
{:ok, ~s({"tier":"review","blast_radius":"x","risks":[],"confidence":0.5})}
182+
end
183+
184+
behavioral = [
185+
%{
186+
severity: :serious,
187+
rule: :ecto_query_analysis,
188+
message: "N+1 in orders",
189+
source: :telemetry,
190+
events: []
191+
}
192+
]
193+
194+
Judge.verdict(block_change(), judge: LLM, completion: completion, run_behavioral: behavioral)
195+
196+
assert_received {:prompt, prompt}
197+
assert prompt =~ "N+1 in orders"
198+
assert prompt =~ "not attributed"
199+
end
200+
end
136201
end

test/review_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,33 @@ defmodule Excessibility.ReviewTest do
113113
assert [%{rule: :stub}] = report.behavioral
114114
end
115115
end
116+
117+
describe "judge_changes/2" do
118+
test "attaches verdicts and recomputes the summary from judged tiers" do
119+
report = Review.review_pairs([{"editor", "<div>Save</div>", ~s(<div phx-click="save">Save</div>)}])
120+
121+
judged = Review.judge_changes(report)
122+
123+
assert [%{verdict: %{source: :heuristic}, tier: :block}] = judged.changes
124+
assert judged.summary.block == 1
125+
end
126+
127+
test "run-level behavioral findings do not escalate per-view tiers" do
128+
# An :auto view change plus an unrelated run-level critical finding:
129+
# the run-level finding must not mark this view :block.
130+
report =
131+
[{"search", ~s(<div aria-live="polite">0</div>), ~s(<div aria-live="polite">1</div>)}]
132+
|> Review.review_pairs()
133+
|> Map.put(:behavioral, [
134+
%{severity: :serious, rule: :stub, message: "N+1 in orders", source: :telemetry, events: []}
135+
])
136+
137+
judged = Review.judge_changes(report)
138+
139+
assert [%{tier: :auto}] = judged.changes
140+
assert judged.summary == %{auto: 1, review: 0, block: 0}
141+
# ...but they are preserved at the report level for the exit gate.
142+
assert [%{message: "N+1 in orders"}] = judged.behavioral
143+
end
144+
end
116145
end

0 commit comments

Comments
 (0)