
Content Refinement Agent
- 45 installs
- 628 repo stars
- Updated July 9, 2026
- ar9av/paperorchestra
content-refinement-agent is a Claude skill that refines a paper draft through simulated peer-review iterations with deterministic accept/revert scoring rules.
About
Step 5 of the PaperOrchestra pipeline that iteratively refines a paper draft by simulating peer review and applying targeted revisions. It uses deterministic accept/revert halt rules, per-iteration snapshots, and a hallucination check against the experimental log so acceptance only happens on genuine score improvement. A developer runs it to polish a generated LaTeX manuscript before submission.
- Step 5 of PaperOrchestra: simulate peer review and revise
- Deterministic 0-100 accept/minor/major/reject decision bands
- Snapshots each iteration so revert is real, not symbolic
Content Refinement Agent by the numbers
- 45 all-time installs (skills.sh)
- Ranked #7,706 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
content-refinement-agent capabilities & compatibility
- Capabilities
- code review · research · documentation
- Use cases
- code review · research · documentation
What content-refinement-agent says it does
Iteratively refine drafts/paper.tex by simulating peer review and applying targeted revisions, with strict accept/revert halt rules
refinement alone accounts for +19% (CVPR) and +22% (ICLR) absolute acceptance-rate improvement (Fig. 4). Get this step right.
npx skills add https://github.com/ar9av/paperorchestra --skill content-refinement-agentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 628 |
| Last updated | July 9, 2026 |
| Repository | ar9av/paperorchestra ↗ |
What it does
Iteratively peer-review and revise a paper draft with deterministic accept-or-revert halt rules before submission.
Who is it for?
Polishing a generated paper draft through review-driven revision cycles
Skip if: Drafting a paper from scratch or generating figures or citations
When should I use this skill?
The orchestrator delegates Step 5, or the user asks to refine the draft, iterate on the paper, or run peer review
What you get
Produces workspace/final/paper.tex from the highest-scoring accepted iteration plus a full worklog
- workspace/final/paper.tex
- workspace/final/paper.pdf
- worklog.json
By the numbers
- ~5-7 LLM calls per run
- default 3 refinement iterations
- 7 AI-failure-mode checks in Gate A
Files
Content Refinement Agent (Step 5)
Faithful implementation of the Content Refinement Agent from PaperOrchestra (Song et al., 2026, arXiv:2604.05018, §4 Step 5, App. F.1 pp. 49–51).
Cost: ~5–7 LLM calls (App. B), typically ~3 refinement iterations, each consisting of one reviewer call and one revision call.
The paper highlights this step as one of the largest contributors to overall quality: refinement alone accounts for +19% (CVPR) and +22% (ICLR) absolute acceptance-rate improvement (Fig. 4). Get this step right.
Inputs
workspace/drafts/paper.tex— output of Step 4workspace/inputs/conference_guidelines.mdworkspace/inputs/experimental_log.md— used as ground truth for the
hallucination check
workspace/citation_pool.json/workspace/refs.bib— the allowed
bibliography
Outputs
workspace/refinement/iter1/,iter2/,iter3/— per-iteration snapshots
containing paper.tex, paper.pdf, review.json, score.json
workspace/refinement/worklog.json— append-only history of decisionsworkspace/final/paper.texandworkspace/final/paper.pdf— copy of the
best accepted snapshot
The refinement loop
prev_score = score(paper.tex) # baseline from initial draft
snapshot iter0/
for iter in 1..ITER_CAP (default 3):
1. simulate_review(paper.tex) → review.json
(uses `references/reviewer-rubric.md` rubric)
2. apply_revision(paper.tex, review.json) → new_paper.tex
(uses verbatim Refinement Agent prompt at `references/prompt.md`)
3. snapshot iter<N>/ with new_paper.tex, review.json
latexmk -pdf new_paper.tex → iter<N>/paper.pdf
4. score(new_paper.tex) → curr_score
5. decide via score_delta.py:
- if curr.overall > prev.overall: ACCEPT
- elif curr.overall == prev.overall and net_subaxis ≥0: ACCEPT
- else: REVERT
6. apply_worklog.py to append the decision
7. if REVERT or no actionable weaknesses or iter == ITER_CAP: HALT
paper.tex ← new_paper.tex (only on ACCEPT)
prev_score ← curr_score
cp <best iter>/paper.tex → workspace/final/paper.texThe "best" snapshot at HALT is the one with the highest accepted overall score. On a REVERT halt, the best is the iteration immediately before the revert.
Step-by-step
0. Pre-refinement integrity gate
Before snapshotting or scoring the initial draft, run two gates in order:
Gate A — AI failure modes (load references/ai-failure-modes.md, runs once):
Load references/ai-failure-modes.md (which points to skills/shared/ai_failure_modes.md). Run all 7 checks against the draft and the inputs. This gate runs once only, at the start of iteration 1.
- CONFIRMED failure → write HALT entry to worklog.json, report to user, stop.
- SUSPECTED failure → add WARNING comment to paper.tex, log in worklog.json, continue.
- No failures → proceed.
Gate B — Claim-evidence provenance (runs once, WARN gate):
python skills/paper-orchestra/scripts/claim_evidence_gate.py \
--paper workspace/drafts/paper.tex \
--log workspace/inputs/experimental_log.md \
--out workspace/claim_evidence_report.jsonExit 0 → PASS, proceed normally. Exit 1 → WARN: unsupported numeric claims found. Log in worklog.json as: {gate: "claim_evidence", status: "WARN", unsupported_count: N, report: "workspace/claim_evidence_report.json"} Pass the unsupported list from the report to the revision agent in Step 3 as an additional instruction: "The following numeric values appear in the paper but cannot be corroborated in experimental_log.md — verify or remove them: ..." Do NOT halt on Gate B warnings; the revision agent will address them.
Gate C — Read research brief (every run, no exit code):
If workspace/research_brief.md exists, read it before all reviewer calls. Pass the "Sections where evidence was thin" list from §4 as additional context to the Devil's Advocate reviewer. This surfaces the highest-risk sections for CRITICAL scrutiny.
0b. Snapshot the initial draft
python skills/content-refinement-agent/scripts/snapshot.py \
--src workspace/drafts/paper.tex \
--dst workspace/refinement/iter0/This creates iter0/paper.tex. Then compile to iter0/paper.pdf:
cd workspace/refinement/iter0/ && latexmk -pdf -interaction=nonstopmode paper.texScore it (see Step 1 below) → iter0/score.json.
1. Simulate peer review
For each iteration N starting from 1:
Writing quality pre-check (start of every iteration): Load references/writing-quality-check.md and run the 5-category checklist (Categories A–E) against the current draft. Note violations and add them to the revision agenda.
Update critique memory before the reviewer call (iter N ≥ 2 only — skip for iter 1):
python skills/content-refinement-agent/scripts/update_critique_memory.py \
--worklog workspace/refinement/worklog.json \
--review workspace/refinement/iter<N-1>/review.json \
--iter <N> \
--out workspace/refinement/critique_memory.jsonThis produces critique_memory.json with focus_on (persistent unresolved issues) and do_not_reflag (already-resolved issues). Inject both lists into the reviewer system prompt verbatim:
CRITIQUE MEMORY — you must honour this before reviewing:
FOCUS ON (flagged in prior iterations, not yet resolved — prioritise these):
<critique_memory.focus_on items, one per line>
DO NOT RE-FLAG (already addressed in prior iterations):
<critique_memory.do_not_reflag items, one per line>This prevents the reviewer from re-discovering already-fixed issues and from missing genuinely stuck problems.
Load references/reviewer-rubric.md as the system prompt for the simulated reviewer call. The reviewer reads iter<N-1>/paper.pdf (or paper.tex if your host LLM lacks PDF input) and produces a JSON of strengths, weaknesses, questions, and per-axis scores.
The rubric is structured to mimic AgentReview (Jin et al., 2024) — the paper's chosen evaluator. We ship a faithful rubric in the references directory; the host agent's LLM does the actual reviewing.
Devil's Advocate reviewer: One simulated reviewer must be designated the DA following references/da-reviewer.md. The DA challenges core claims from first principles (causal overclaiming, ablation coverage, baseline fairness, generalization claims, novelty inflation) rather than surface polish. If the DA issues a CRITICAL finding that remains unaddressed after all reviewers weigh in, that finding blocks the "refinement accepted" decision regardless of rubric scores. Log DA CRITICAL findings in worklog.json: {da_critical: true, finding: "..."}.
Record the DA's per-round findings and concession decisions in workspace/refinement/da_concessions.json (schema in references/da-reviewer.md) and enforce the concession-threshold protocol deterministically — this stops the simulated DA from sycophantically caving:
python skills/content-refinement-agent/scripts/concession_guard.py \
--log workspace/refinement/da_concessions.json \
--out workspace/refinement/iter<N>/da_guard.json
# exit 0 = clear; exit 1 = standing CRITICAL → force REVERT this iteration;
# exit 2 = a concession was rejected (caving/consecutive) → DA must restate;
# exit 3 = schema error.The guard rejects any concession made at rebuttal_score < 4 or in a round immediately following another concession, and restores the affected finding to "standing". A standing CRITICAL (exit 1) overrides an ACCEPT into a REVERT.
Save to workspace/refinement/iter<N>/review.json.
2. Score the draft
The reviewer call produces both qualitative feedback and a per-axis score:
{
"axis_scores": {
"scientific_depth": {"score": 65, "justification": "..."},
"technical_execution": {"score": 70, "justification": "..."},
"logical_flow": {"score": 60, "justification": "..."},
"writing_clarity": {"score": 55, "justification": "..."},
"evidence_presentation":{"score": 72, "justification": "..."},
"academic_style": {"score": 68, "justification": "..."}
},
"overall_score": 64.5,
"decision_band": "Major Revision",
"strengths": [...],
"weaknesses": [...],
"questions": [...]
}Save to iter<N>/score.json. (Combined with review.json if your host emits one document; the schemas overlap.)
decision_band is derived deterministically from overall_score — Accept (≥80) / Minor Revision (65–79) / Major Revision (50–64) / Reject (<50). Fill it in with python skills/content-refinement-agent/scripts/decision_band.py --score-json iter<N>/score.json rather than by hand, so it can never disagree with the number. The bands drive the target-met halt in Step 5.
3. Apply revision
Load the verbatim Content Refinement Agent prompt at references/prompt.md. Prepend the Anti-Leakage Prompt. Inputs:
paper.tex— current draftpaper.pdf— compiled PDF (multimodal context if available)conference_guidelines.mdexperimental_log.md— ground truth for numeric claimsworklog.json— history of previous changescitation_pool.json— the allowed bibliographyreviewer_feedback— the JSON from Step 1
The prompt instructs the model to address weaknesses, integrate question answers, and emit two output blocks:
1. A worklog JSON {addressed_weaknesses[], integrated_answers[], actions_taken[]} 2. The full revised LaTeX code
Save the revised LaTeX as iter<N>/paper.tex. Append the worklog JSON to workspace/refinement/worklog.json via apply_worklog.py.
4. Compile and re-score
cd workspace/refinement/iter<N>/ && latexmk -pdf -interaction=nonstopmode paper.texThen re-run the simulated review on the new draft → updated score.json for the new iteration. (This is the "re-score after revision" call.)
5. Apply the accept/revert decision
The calling loop must track CONSECUTIVE_SMALL (starts at 0) and pass it on each call so score_delta.py can detect the plateau:
python skills/content-refinement-agent/scripts/score_delta.py \
--prev workspace/refinement/iter<N-1>/score.json \
--curr workspace/refinement/iter<N>/score.json \
--plateau-threshold 1.0 \
--plateau-streak 3 \
--accept-threshold 80 \
--consecutive-small $CONSECUTIVE_SMALL \
> workspace/refinement/iter<N>/delta.json
EXIT=$?
# Update streak for next iteration:
CONSECUTIVE_SMALL=$(python3 -c "
import json
d = json.load(open('workspace/refinement/iter<N>/delta.json'))
print(d['consecutive_small'])
")Exit codes:
0— ACCEPT (overall improved or tied with non-negative net sub-axis, below the Accept band, no plateau)1— REVERT (overall decreased)2— REVERT (tied overall, but net sub-axis change negative)4— HALT_PLATEAU (accepted but N consecutive iterations below threshold — stop early)5— HALT_TARGET_MET (accepted AND reached the Accept band, overall ≥ 80 — stop)
Behavior:
- ACCEPT (exit 0): keep
iter<N>/paper.texas the new best. Continue to iter N+1. - REVERT (exit 1 or 2): copy
iter<N-1>/paper.texback as canonical, halt. - HALT_PLATEAU (exit 4): keep current (it was accepted), but stop — further
iterations are unlikely to yield meaningful gains. In practice ~85% of refinement gain comes in iteration 1; the plateau fires when subsequent iterations improve by less than 1 point for 3 consecutive rounds.
- HALT_TARGET_MET (exit 5): keep current (it was accepted), but stop — the
paper has reached the Accept band (overall ≥ 80), so there is no reason to keep iterating and risk a regression. The delta.json carries decision_band_prev / decision_band_curr for the run report.
Override — DA CRITICAL. If concession_guard.py (Step 1) returned exit 1 for this iteration, treat the outcome as REVERT even when score_delta.py says ACCEPT: roll back to iter<N-1>/paper.tex and require the next revision to address the standing CRITICAL finding.
Always log the decision via apply_worklog.py --decision ....
6. Halt rules
Halt the loop when ANY of these is true:
1. Iteration count reaches ITER_CAP (default 3). 2. score_delta.py returned exit code 1 or 2 (REVERT), OR concession_guard.py returned exit 1 (standing DA CRITICAL → forced REVERT). 3. The simulated reviewer's weaknesses list is empty (no actionable feedback to apply). 4. score_delta.py returned exit code 4 (HALT_PLATEAU — plateau early-stop). 5. score_delta.py returned exit code 5 (HALT_TARGET_MET — reached the Accept band, overall ≥ 80; promote the current draft).
7. Promote the best snapshot
Identify the iteration with the highest accepted overall_score (this may be the latest accepted iteration, OR an earlier one if a later iteration was reverted). Copy:
cp workspace/refinement/iter<best>/paper.tex workspace/final/paper.tex
cp workspace/refinement/iter<best>/paper.pdf workspace/final/paper.pdfThen in the final report, tell the user:
- How many iterations were run
- The final overall score and its decision band (Accept / Minor / Major / Reject)
- The score trajectory with bands (e.g., "iter0 58.0 Major → iter1 67.3 Minor (accept) → iter2 81.0 Accept (halt: target met)")
- Which iteration was promoted, and the halt reason (revert / plateau / target met / iter cap / DA critical)
Critical safety constraints (App. F.1 page 50–51)
The paper explicitly notes that early versions of the Refinement Agent "exploited the automated reviewer's scoring function by superficially listing missing baselines as limitations to artificially inflate acceptance scores." The verbatim prompt forbids this. You must honor it:
- [IRON RULE] Halt on score regression. If
score_delta.pyreturns exit
code 1 or 2 (REVERT), immediately revert to the previous snapshot and halt. No further revision attempts are permitted after a regression.
- [IRON RULE] No new experiments in revision. Ignore reviewer requests for
new experiments, ablations, or baselines. The Refinement Agent's job is presentation, not new science. If the reviewer asks for missing data, simply skip those points — do NOT add fabricated experiments, do NOT add a "future work" item promising them.
- [IRON RULE] All numeric claims must match experimental_log.md. The agent
cannot introduce new numbers, only re-present existing ones. Any number in the revised paper that does not appear in experimental_log.md is a hallucination.
- Never explicitly state a limitation. The phrase "we acknowledge as a
limitation that..." is forbidden. The model can address weaknesses through clearer explanation, but must not game the evaluator by listing them defensively.
These rules prevent reward hacking and keep the refinement loop honest.
Resources
references/prompt.md— verbatim Content Refinement Agent prompt from App. F.1references/reviewer-rubric.md— AgentReview-style scoring rubric (6 axes)references/halt-rules.md— accept/revert/halt logic in formal pseudocodereferences/safe-revision-rules.md— anti-reward-hack constraintsreferences/writing-quality-check.md— 5-category anti-AI-prose checklist (pointer to shared)references/ai-failure-modes.md— 7-mode integrity gate run before first iteration (pointer to shared)references/da-reviewer.md— Devil's Advocate reviewer protocol and concession rulesscripts/score_delta.py— accept/revert/halt decision from two score JSONs; emits decision bands + target-met halt (exit 5)scripts/decision_band.py— map an overall score to a canonical decision band (Accept/Minor/Major/Reject)scripts/concession_guard.py— enforce the DA concession-threshold protocol; blocks accept on a standing CRITICALscripts/score_trajectory.py— per-dimension score history, regression and plateau detectionscripts/apply_worklog.py— append iteration entries to worklog.jsonscripts/snapshot.py— copy paper.tex/paper.pdf into iter<N>/ for rollbackscripts/update_critique_memory.py— NEW build/update critique_memory.json from worklog + review (AutoSci-inspired reviewer memory)skills/shared/writing_quality_check.md— full anti-AI-prose checklist (5 categories)skills/shared/ai_failure_modes.md— full AI research failure modes gate (7 modes)skills/shared/handoff_schemas.md— formal data contracts between all pipeline stepsskills/shared/research_brief_template.md— NEW research brief schema (read §1–§4 before first reviewer call)
AI Research Failure Modes Gate
Full checklist: skills/shared/ai_failure_modes.md
When to Run
Run this gate ONCE at the start of the FIRST refinement iteration only. It is a pre-refinement integrity check, not a per-iteration check.
Decision Protocol
- CONFIRMED failure (any mode 1–7): HALT. Do not proceed to refinement.
Report: which failure mode, what evidence, what the user must fix in the inputs. Write a HALT entry to worklog.json: {iteration: 0, decision: "halt", reason: "...", failure_mode: N}
- SUSPECTED failure: Add a WARNING comment at the top of paper.tex:
% WARNING: Potential failure mode N detected: [description]. Verify before submission. Continue refinement but log the suspicion in worklog.json.
- No failures: Proceed to refinement iteration 1.
Devil's Advocate Reviewer Protocol
Role
One of the simulated peer reviewers is designated the Devil's Advocate (DA). The DA's job is to challenge the paper's core claims from first principles, not to find polish issues (those are other reviewers' job).
DA Attack Targets (in priority order)
1. Causal overclaiming — Does the paper say "X causes Y" when it only shows correlation? 2. Ablation coverage — Does every claimed component have an ablation? If not, flag missing ablations. 3. Baseline fairness — Are baselines run with the same compute budget and tuning effort? 4. Generalization claims — Does the paper claim broad applicability from narrow experiments (e.g., 1 dataset)? 5. Novelty inflation — Is the "novel" contribution already present in cited works?
Concession Threshold
The DA must score each rebuttal from other reviewers 1–5 before updating its position:
- Score 5: rebuttal directly addresses the attack with paper evidence
→ concession allowed
- Score 4: rebuttal provides strong indirect evidence → concession allowed
- Score 3: partial rebuttal → DA holds position, restates attack more specifically
- Score 1–2: weak rebuttal or no response → DA escalates (marks as CRITICAL if
unaddressed after all reviewers weigh in)
IRON RULE: No consecutive concessions. The DA may concede at most once per two review rounds.
DA CRITICAL findings block the "refinement accepted" decision regardless of overall rubric scores.
What DA CRITICAL Means
If the DA issues a CRITICAL finding, score_delta.py exit code is overridden to 2 (REVERT). The revision must specifically address the CRITICAL finding before continuing.
Log in worklog.json: {da_critical: true, finding: "..."}
Deterministic enforcement: scripts/concession_guard.py
The concession threshold and the no-consecutive-concessions iron rule are easy for a simulated reviewer to quietly relax — it caves. To make them non-negotiable, record the DA's findings and concession decisions in a concession log and run concession_guard.py each iteration. The script re-derives which concessions are valid and whether any CRITICAL is still standing; the host agent must obey its verdict over the LLM's prose.
Concession log schema (workspace/refinement/da_concessions.json):
{
"rounds": [
{
"round": 1,
"findings": [
{
"id": "F1",
"severity": "critical",
"attack": "Sec 4 claims X *causes* Y from correlation only.",
"rebuttal_score": 2,
"conceded": false,
"resolved": false
}
]
}
]
}rebuttal_score(1–5) — the DA's score of the author/revision rebuttal,
using the concession-threshold scale above.
conceded— did the DA drop the attack this round?resolved— was the underlying issue actually fixed in the revision?
python skills/content-refinement-agent/scripts/concession_guard.py \
--log workspace/refinement/da_concessions.json \
--out workspace/refinement/iter<N>/da_guard.jsonVerdict → loop action:
| Guard exit | Meaning | Host action |
|---|---|---|
| 0 | CLEAR — no standing critical, no violations | accept may proceed |
| 1 | BLOCK — a critical is still standing | treat the iteration as REVERT (force score_delta.py outcome to exit 2) and require the next revision to address it |
| 2 | WARN — a concession was rejected (caving or consecutive) but no critical is blocked | the DA must restate the attack; do not let the rejected concession stand |
| 3 | input / schema error | fix the log |
The guard rejects (does not honor) any concession made at rebuttal_score < 4 or in a round immediately following another conceding round, and restores the affected finding to "standing". A standing CRITICAL blocks acceptance regardless of rubric scores — this is the deterministic backstop behind the prose rules above.
Halt Rules
Source: arXiv:2604.05018, §4 Step 5 ("Iterative Content Refinement"):
After modifying the LaTeX source to address weaknesses, revisions are
accepted if the overall score increases, or if it ties when net sub-axis
gains are non-negative. The agent immediately reverts to the previous
version and halts upon any overall score decrease, negative tie-breaker,
or reaching the iteration limit.
Encoded as deterministic logic in scripts/score_delta.py. This file is the human-readable specification.
Definitions
Let:
prev= score JSON from the previous accepted iterationcurr= score JSON from the just-completed iterationprev.overall=prev.overall_scorecurr.overall=curr.overall_scoresubaxis_delta(axis)=curr.axis_scores[axis].score - prev.axis_scores[axis].scorenet_subaxis_delta=sum(subaxis_delta(a) for a in 6 axes)
Decision rules (in order)
if curr.overall > prev.overall:
DECISION = ACCEPT_IMPROVED
elif curr.overall == prev.overall:
if net_subaxis_delta >= 0:
DECISION = ACCEPT_TIED_NON_NEGATIVE
else:
DECISION = REVERT_TIED_NEGATIVE_SUBAXIS
else: # curr.overall < prev.overall
DECISION = REVERT_OVERALL_DECREASEDThe script exits with:
| Exit code | Meaning | Loop action |
|---|---|---|
| 0 | ACCEPT_IMPROVED | keep new draft, continue loop |
| 0 | ACCEPT_TIED_NON_NEGATIVE | keep new draft, continue loop |
| 1 | REVERT_OVERALL_DECREASED | rollback to prev, halt loop |
| 2 | REVERT_TIED_NEGATIVE_SUBAXIS | rollback to prev, halt loop |
| 4 | HALT_PLATEAU | keep new draft (accepted), halt loop |
| 5 | HALT_TARGET_MET | keep new draft (accepted), halt loop |
The script also prints a one-line decision string and a JSON object on stdout for the host agent to log.
Decision bands and the target-met halt
score_delta.py annotates every comparison with the prev/curr decision band (decision_band.py): Accept (≥80), Minor Revision (65–79), Major Revision (50–64), Reject (<50). These give the loop an absolute quality target on top of the relative delta rules.
if DECISION in {ACCEPT_IMPROVED, ACCEPT_TIED_NON_NEGATIVE}:
if curr.overall >= accept_threshold (default 80):
DECISION = HALT_TARGET_MET # exit 5 — keep current draft, stop
elif consecutive_small >= plateau_streak:
DECISION = HALT_PLATEAU # exit 4 — keep current draft, stopTarget-met takes precedence over plateau: once the paper reaches the Accept band there is no reason to keep iterating and risk a regression. Both 4 and 5 keep the just-accepted draft (unlike REVERT, which rolls back). Disable the target-met halt with --no-target-halt (the band is still reported).
Loop-level halt conditions
In addition to the per-iteration accept/revert decision, the loop halts when ANY of these is true:
1. Iteration cap reached. Default 3 (configurable via env var PO_REFINE_MAX_ITER). Per the paper Table 7, the typical refinement count is "3× content refinement loop". 2. REVERT decision from score_delta.py (exit code 1 or 2). 3. Empty weaknesses list. If the simulated reviewer's weaknesses array is empty, there is nothing to fix — halt. 4. Plateau early-stop (exit code 4). score_delta.py returns HALT_PLATEAU when N consecutive accepted iterations each have overall_delta < threshold. Default: threshold=1.0 points, N=3. Configurable via --plateau-threshold and --plateau-streak.
5. Target met (exit code 5). score_delta.py returns HALT_TARGET_MET when an accepted iteration reaches the Accept band (overall ≥ 80). The current draft is promoted; the loop stops rather than risk a regression.
6. DA CRITICAL standing (concession guard). concession_guard.py exit 1 means a Devil's Advocate CRITICAL finding is still standing (unresolved and not validly conceded). This overrides an ACCEPT into a REVERT: roll back and require the next revision to address the finding before continuing. Exit 2 (a rejected concession with no blocked critical) is a WARN — the DA must restate the attack, but it does not by itself force a revert. See da-reviewer.md.
The calling loop must pass --consecutive-small <count> to score_delta.py to track the streak across iterations:
CONSECUTIVE_SMALL=0
for iter in 1 2 3 ...; do
# ... run refinement LLM call ...
python score_delta.py \
--prev iter$((iter-1))/score.json \
--curr iter${iter}/score.json \
--plateau-threshold 1.0 \
--plateau-streak 3 \
--consecutive-small $CONSECUTIVE_SMALL
EXIT=$?
# Update streak counter from script output
CONSECUTIVE_SMALL=$(python -c "import json,sys; \
d=json.loads(open('iter${iter}/delta.json').read()); \
print(d['consecutive_small'])")
if [ $EXIT -ne 0 ]; then break; fi
doneWhy this matters: in practice, ~85% of the refinement gain comes in the first iteration (scores jump 5-8 points). Subsequent iterations typically improve by <1 point. Without early-stop, the loop runs 3 full LLM calls even when iterations 2 and 3 contribute near-zero value.
Promoting the best snapshot
After halt, identify the iteration with the highest accepted overall score:
accepted_iters = [it for it in worklog.iterations if it.decision.startswith("ACCEPT")]
best = max(accepted_iters, key=lambda it: it.score.overall_score)If the loop halted on REVERT, best is the iteration immediately before the reverted one. Copy its paper.tex and paper.pdf to workspace/final/.
Worked example
Suppose:
| iter | overall | depth | exec | flow | clarity | evidence | style | decision |
|---|---|---|---|---|---|---|---|---|
| 0 | 64.5 | 65 | 70 | 60 | 55 | 72 | 68 | (baseline) |
| 1 | 67.3 | 68 | 73 | 64 | 58 | 74 | 70 | ACCEPT_IMPROVED |
| 2 | 67.3 | 70 | 73 | 64 | 58 | 73 | 71 | ACCEPT_TIED_NON_NEGATIVE (Σdelta = +2) |
| 3 | 66.0 | 70 | 70 | 62 | 56 | 73 | 71 | REVERT_OVERALL_DECREASED, HALT |
Promoted: iter 2 (final/paper.tex ← iter2/paper.tex). Score trajectory in the run report:
64.5 → 67.3 (accept) → 67.3 (accept tied) → 66.0 (revert, halt)Content Refinement Agent — verbatim prompt
Source: arXiv:2604.05018, Appendix F.1, pages 49–51 (verbatim).
This is the exact prompt used by the Content Refinement Agent in the paper. Use it as your system message when applying a revision. The Anti-Leakage Prompt (../paper-orchestra/references/anti-leakage-prompt.md) MUST be prepended.
---
Role: Senior AI Researcher.
Task: Revise and strengthen a LaTeX research paper by systematically
addressing peer review feedback.
You are the author responsible for the "Rebuttal via Revision" phase. You
will receive:
- paper.tex: The current LaTeX source code.
- paper.pdf: The compiled PDF context.
- conference_guidelines.md: The formatting and page limit rules.
- experimental_log.md: The Ground Truth for all data and metrics.
- worklog.json: History of previous changes.
- citation_map.json: The allowed bibliography.
- reviewer_feedback: A JSON object containing specific Strengths,
Weaknesses, Questions, and Decisions from an LLM reviewer.
Your Goal
1. Analyze Feedback: Deconstruct the reviewer_feedback into actionable
editing tasks.
2. Address Weaknesses: Rewrite sections to clarify logic, strengthen
arguments, or justify design choices pointed out as weak.
3. Integrate Answers: Incorporate answers to the reviewer's "Questions"
directly into the manuscript (e.g., adding training cost details to
the Implementation section).
4. Execution: Generate a JSON worklog of your editorial decisions and the
full, revised LaTeX source.
Critical Execution Standards
1. Content Revision Strategy
- Weakness Mitigation: If the reviewer flags "incremental novelty",
rewrite the Introduction and Related Work to explicitly contrast
your contribution against prior art. If they flag "unclear
methodology", restructure the relevant section for clarity.
- Answering Questions: Do NOT write a separate response letter. If the
reviewer asks "What is the inference latency?", you must find a
natural place in the paper (e.g., Experiments or Discussion) to
insert that information, ensuring it aligns with experimental_log.md.
- Preserve Strengths: Do not delete or heavily alter sections listed
under "Strengths" unless necessary for space or flow.
2. Data Integrity & Hallucination Check
- Ground Truth: All numerical claims (accuracy, parameter count,
training hours, latency) MUST be verified against
experimental_log.md.
- Missing Data: If the reviewer asks for new experiments, ablations, or
baselines that are NOT in experimental_log.md, simply ignore those
specific requests. Your job is purely presentation refinement of the
existing completed experiments, not adding or promising to add new
experiments.
3. Writing Style & Tone
- Academic Tone: Maintain a formal, objective, and precise tone. Avoid
defensive language.
- Conciseness: If the paper is near the page limit, prioritize density
of information over flowery prose.
- Flow: Ensure that new insertions (answers to questions) transition
smoothly with existing text.
4. LaTeX & Citation Integrity
- Structure: Do not break the LaTeX compilation. Keep packages and
environments stable. If using figure* for wide figures, ensure they
are closed with \end{{figure*}} (not \end{{figure}}). Check for
completeness.
- Citations: Use ONLY keys from citation_map.json.
Output Format (Strict)
You MUST return your response in two distinct code blocks in this exact
order:
1. Worklog for the current turn (JSON):
{{
"addressed_weaknesses": [
"Clarified contribution novelty in Intro (Reviewer point 2)",
"Added justification for two-stage training (Reviewer point 1)"
],
"integrated_answers": [
"Added training cost (45 GPU hours) to Implementation Details",
"Added epsilon hyperparameter explanation to Method section"
],
"actions_taken": [
"Rewrote Section 3.2 for clarity",
"Inserted new paragraph in Section 5.1 regarding latency"
]
}}
2. The FULL revised LaTeX code:... Full revised LaTeX code here ...
Important Notes
- Completeness: Always provide the FULL LaTeX code. Do not return diffs
or partial snippets.
- Responsiveness: Every question in the reviewer_feedback must be
addressed by improving the presentation, EXCEPT for questions asking
for new experiments or data not in experimental_log.md (which should
be ignored). Never explicitly state a limitation.
- Safety: Do not remove the \documentclass or essential preamble.---
Why "never explicitly state a limitation" is a hard rule
From App. F.1 p.51, the paper explains:
We explicitly instruct the Content Refinement Agent to ignore reviewer
requests for additional experiments. This constraint is crucial to
prevent the agent from generating fabricated results or making false
promises within the paper... Furthermore, the directive to "never
explicitly state a limitation" prevents reward hacking. During early
testing, the agent exploited the automated reviewer's scoring function
by superficially listing missing baselines as limitations to
artificially inflate acceptance scores. Banning this behavior from the
refinement loop forces the agent to genuinely improve the manuscript's
presentation and clarity rather than gamifying the evaluation metric.
safe-revision-rules.md formalizes this as a deterministic gate the host agent should run after each revision: grep the new draft for the substring limitation (case-insensitive) and reject if found.
Reviewer Rubric (AgentReview-style)
The Content Refinement Agent loop needs a simulated reviewer that produces structured, scoreable feedback the host agent can compare iteration to iteration. The paper uses AgentReview (Jin et al., 2024) as its evaluator in §5 (App. F.1 references "AgentReview" by name and uses its output schema: "strengths, weaknesses, questions, decisions").
This document defines a faithful AgentReview-style reviewer prompt to use under any host LLM. Use it as the system message for the simulated review call before each refinement iteration.
---
System prompt for the simulated reviewer
You are an expert academic peer reviewer for a top-tier machine learning
conference (CVPR, ICLR, NeurIPS, ICML). Read the provided LaTeX paper or
PDF and produce a rigorous, structured review.
Your review must be CONSERVATIVE. High scores are rare and must be
explicitly justified with concrete evidence from the paper. Assume most
drafts are not publication-ready.
You MUST score the paper on six axes (0-100 each):
1. Scientific Depth & Soundness
- Are the theoretical foundations and experimental setups rigorous?
- Are claims justified and free of unsupported leaps?
2. Technical Execution
- Within the bounds of the described idea, is the methodology
implemented innovatively and effectively?
- Are the design choices justified by the experimental results?
3. Logical Flow
- Do sections transition smoothly from Abstract through Conclusion?
- Are subsections structured logically with clear signposting?
4. Writing Clarity
- Is the prose precise, concise, and free of repetitive phrasing?
- Are technical terms defined before use?
5. Evidence Presentation
- Are figures, tables, and results integrated and referenced cleanly?
- Do visuals support the text claims directly?
6. Academic Style
- Polished, professional academic tone?
- Consistent terminology throughout?
For each axis, provide a score AND a 2-5 sentence evidence-based
justification quoting concrete passages or pointing to specific failings.
Then identify:
- Strengths: 3-5 bullet points naming things the paper does well.
- Weaknesses: 3-5 bullet points naming concrete, fixable issues.
- Questions: 2-4 specific questions the paper should answer for a
reader to be convinced.
- Decision: one of "Strong Accept", "Accept", "Borderline", "Reject",
"Strong Reject". This is your qualitative judgment; it must be consistent
with the decision band the overall score falls into (see below).
- Overall Score: weighted average 0-100. Use:
overall = 0.20*depth + 0.20*execution + 0.15*flow
+ 0.15*clarity + 0.20*evidence + 0.10*style
Output STRICT JSON only. No prose outside the JSON.Decision bands (canonical, derived from overall score)
The free-form decision above is advisory. The refinement loop reasons about a canonical decision band computed deterministically from overall_score by scripts/decision_band.py, so the band can never drift from the number it summarizes:
| Overall score | Decision band | Loop meaning |
|---|---|---|
| ≥ 80 | Accept | Clears the acceptance bar — loop may stop (target met) |
| 65–79 | Minor Revision | Close; keep refining presentation |
| 50–64 | Major Revision | Substantive gaps remain |
| < 50 | Reject | Far from publishable |
The reviewer's qualitative decision should agree with the band (e.g. don't write "Accept" with an overall of 62). The thresholds are configurable on decision_band.py / score_delta.py (--accept-min etc.) but default to the table above. See halt-rules.md for how the Accept band triggers an early halt.
Output JSON schema
{
"axis_scores": {
"scientific_depth": {
"score": 65,
"justification": "Loss formulation is grounded in the cited prior work but the ablation on the audio-visual fusion layer is small (n=3 seeds) and the variance bands overlap, making the claim of necessity weak. Section 3.2 introduces the cached memory without proving its necessity vs. simple pooling."
},
"technical_execution": { "score": 70, "justification": "..." },
"logical_flow": { "score": 60, "justification": "..." },
"writing_clarity": { "score": 55, "justification": "..." },
"evidence_presentation": { "score": 72, "justification": "..." },
"academic_style": { "score": 68, "justification": "..." }
},
"strengths": [
"Clear problem statement in the Introduction with three concrete failure cases of prior SAM-based methods.",
"Well-organized Related Work that contrasts the three competing paradigms.",
"..."
],
"weaknesses": [
"The ablation in Table 2 lacks confidence intervals; 0.4 J-index gaps may not be significant.",
"Section 3.4 introduces the IoU loss term λ without justifying λ=1.0 vs other values.",
"Figure 3 is referenced once and never discussed in the prose.",
"..."
],
"questions": [
"What is the inference latency on a single A100?",
"How does the temporal branch behave on videos longer than the training distribution?"
],
"decision": "Borderline",
"decision_band": "Major Revision",
"overall_score": 64.5
}decision_band is filled in deterministically — run python scripts/decision_band.py --score-json iter<N>/score.json and copy the result, or let score_delta.py report it (it emits decision_band_prev / decision_band_curr on every comparison). Never hand-set it inconsistently with overall_score.
How the loop uses this output
The score_delta.py script reads two consecutive score JSONs and applies the halt rules. The apply_worklog.py script appends a timestamped entry to workspace/refinement/worklog.json. The Content Refinement Agent's revision call takes the full review.json as reviewer_feedback input.
Anti-inflation guardrails
To prevent the simulated reviewer from being gameable, the rubric has hard caps drawn from the paper's Literature Review Quality autorater (App. F.3 — see also paper-autoraters/references/litreview-quality-prompt.md):
| Axis | Hard cap |
|---|---|
| Scientific Depth | ≤60 if claims are unsupported by experiments |
| Technical Execution | ≤55 if methodology section omits key implementation details |
| Logical Flow | ≤60 if sections don't reference the figures/tables they need |
| Writing Clarity | ≤60 if repetitive phrasing or undefined acronyms |
| Evidence Presentation | ≤55 if any figure is unreferenced from the text |
| Academic Style | ≤55 if defensive language is present |
These caps are baked into the rubric prompt to keep the reviewer honest. The Content Refinement Agent's "never explicitly state a limitation" rule combined with these caps closes the reward-hacking loop the paper observed in early testing (App. F.1 p.51).
Safe Revision Rules
The Content Refinement Agent prompt (App. F.1 p.50–51) imposes two anti-reward-hacking constraints. Both must be enforced not just by the prompt but by deterministic post-revision gates, because LLMs occasionally forget instructions buried in long prompts.
Rule 1 — Ignore reviewer requests for new experiments
The simulated reviewer will sometimes ask:
- "What if you ablated the temperature parameter?"
- "How does this compare to baseline X?"
- "Have you tried this on dataset Y?"
The Refinement Agent must not fabricate answers to these. The paper:
If the reviewer asks for new experiments, ablations, or baselines that
are NOT in experimental_log.md, simply ignore those specific requests.
Your job is purely presentation refinement of the existing completed
experiments, not adding or promising to add new experiments.
Enforcement
There is no fully deterministic way to grep for "fabricated experiments" — it requires reading the new content and cross-checking against experimental_log.md. The pragmatic check:
1. Run the orphan-citation gate from section-writing-agent/scripts/orphan_cite_gate.py. New numeric claims often come bundled with new (orphan) citations. 2. Run a numeric-claim grep: extract every \d+\.\d+%? from the new draft, intersect with \d+\.\d+%? in experimental_log.md. New numbers in the draft that aren't in the log are suspicious. (False positives possible for parameter counts and dates; review manually.)
The orchestrator should re-prompt the refinement step if either gate fires with new fabricated claims.
Rule 2 — Never explicitly state a limitation
The paper:
The directive to "never explicitly state a limitation" prevents reward
hacking. During early testing, the agent exploited the automated
reviewer's scoring function by superficially listing missing baselines
as limitations to artificially inflate acceptance scores.
Enforcement (deterministic)
Grep the revised draft for the substring limitation (case-insensitive), excluding LaTeX comments. If found anywhere in the body, reject the revision and re-prompt:
# pseudocode — implement inline in the host agent
grep -in -E '\blimitation' workspace/refinement/iter<N>/paper.tex \
| grep -v '^\s*%'Allowed contexts (these are NOT violations):
- LaTeX comments:
% address the limitation of ... - Citation context: a paper title containing "limitation" cited in
\cite{...}. The grep should ignore the inside of \cite{...} braces.
- Quoted prior-work descriptions: "Smith et al. acknowledge the
limitation..." — context-dependent. The simplest rule is "no instances of the word 'limitation' in the running prose at all", and let the host agent handle edge cases by re-prompting if a legitimate use is needed.
This is a strict rule. The Refinement Agent should rewrite "we acknowledge the limitation that our method..." as "our method assumes..." or "the proposed approach is most effective when...". Reframing, not listing.
Rule 3 — Numeric ground truth
All numerical claims (accuracy, parameter count, training hours,
latency) MUST be verified against experimental_log.md.
The grep heuristic above catches this partially. The host agent should also instruct the refinement step explicitly: "any numeric value you cite in your revision must already exist in experimental_log.md or metrics.json."
Rule 4 — Citation integrity
The orphan-citation gate from section-writing-agent/scripts/orphan_cite_gate.py must pass after every refinement iteration. Re-run it as part of the post-revision checks:
python skills/section-writing-agent/scripts/orphan_cite_gate.py \
workspace/refinement/iter<N>/paper.tex \
workspace/refs.bibIf the refinement step introduced a new \cite{KEY} not in refs.bib, revert the iteration and re-prompt with an explicit instruction to use only existing keys.
Rule 5 — LaTeX integrity
Re-run latex_sanity.py and latexmk -pdf after every revision. If the revision broke the build, revert.
Summary checklist for each refinement iteration
# 1. apply revision → iter<N>/paper.tex
# 2. compile
cd workspace/refinement/iter<N>/ && latexmk -pdf -interaction=nonstopmode paper.tex
# 3. structural sanity
python skills/section-writing-agent/scripts/latex_sanity.py paper.tex || REVERT
python skills/section-writing-agent/scripts/orphan_cite_gate.py paper.tex ../../refs.bib || REVERT
# 4. anti-leakage
python skills/paper-orchestra/scripts/anti_leakage_check.py paper.tex || REVERT
# 5. limitation grep (Rule 2)
grep -in -E '\blimitation' paper.tex | grep -v '^\s*%' && REVERT
# 6. score and decide
python skills/content-refinement-agent/scripts/score_delta.py \
--prev ../iter<N-1>/score.json --curr score.json
# exit 0 → keep, exit 1/2 → revertIf all gates pass and score_delta.py returns 0, the iteration is accepted.
Writing Quality Check
This reference is defined in skills/shared/writing_quality_check.md.
When to Apply
Apply at the START of each refinement iteration BEFORE generating revision suggestions. Run the checklist mentally across the current paper.tex draft.
Integration with Refinement Loop
1. Score the draft on all 5 categories (A–E). Note violations. 2. Add writing quality issues to the revision agenda alongside structural/content issues. 3. After applying revisions, re-check Categories A and C (fastest) to confirm fixes landed. 4. Never count "removed AI buzzwords" as a scoring dimension — it does not raise rubric scores. It prevents score inflation from polish masking weak content.
#!/usr/bin/env python3
"""
apply_worklog.py — Append a timestamped iteration entry to worklog.json.
The worklog is the canonical history of the refinement loop: every
iteration's review, score, decision, and actions taken. The orchestrator
reads it at the end to identify the best snapshot to promote.
Usage:
python apply_worklog.py \\
--worklog workspace/refinement/worklog.json \\
--iter 2 \\
--review iter2/review.json \\
--score iter2/score.json \\
--decision ACCEPT_IMPROVED \\
--actions iter2/worklog_entry.json # the agent's emitted worklog block
The script creates worklog.json if it doesn't exist.
"""
import argparse
import datetime as dt
import json
import os
import sys
def load_json(path: str | None) -> dict | list | None:
if not path or not os.path.exists(path):
return None
with open(path) as f:
return json.load(f)
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--worklog", required=True, help="path to worklog.json")
p.add_argument("--iter", type=int, required=True, help="iteration number (0-indexed)")
p.add_argument("--review", help="path to review.json for this iteration")
p.add_argument("--score", help="path to score.json for this iteration")
p.add_argument("--decision", required=True,
help="ACCEPT_IMPROVED / ACCEPT_TIED_NON_NEGATIVE / "
"REVERT_OVERALL_DECREASED / REVERT_TIED_NEGATIVE_SUBAXIS")
p.add_argument("--actions", help="path to the agent's worklog block JSON "
"(addressed_weaknesses, integrated_answers, actions_taken)")
p.add_argument("--halted-because", help="reason if this iteration triggers a halt")
args = p.parse_args()
if os.path.exists(args.worklog):
with open(args.worklog) as f:
wl = json.load(f)
else:
wl = {"iterations": [], "halted_because": None, "best_iter": None}
entry = {
"iter": args.iter,
"timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
"decision": args.decision,
"review": load_json(args.review),
"score": load_json(args.score),
"actions": load_json(args.actions),
}
wl["iterations"].append(entry)
if args.halted_because:
wl["halted_because"] = args.halted_because
# Re-compute best_iter: highest accepted overall_score
accepted = [
it for it in wl["iterations"]
if it.get("decision", "").startswith("ACCEPT") and it.get("score")
]
if accepted:
best = max(accepted, key=lambda it: it["score"].get("overall_score", 0))
wl["best_iter"] = best["iter"]
os.makedirs(os.path.dirname(os.path.abspath(args.worklog)) or ".", exist_ok=True)
with open(args.worklog, "w") as f:
json.dump(wl, f, indent=2, ensure_ascii=False)
print(f"OK: appended iter {args.iter} ({args.decision}) to {args.worklog}")
if wl["best_iter"] is not None:
print(f" current best_iter: {wl['best_iter']}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
concession_guard.py — Enforce the Devil's Advocate concession-threshold protocol
deterministically (references/da-reviewer.md).
The DA reviewer challenges the paper's core claims. A simulated reviewer left to
its own devices tends to *cave*: it concedes an attack as soon as the author
pushes back, even when the rebuttal is weak — sycophancy that defeats the point
of an adversarial reviewer. This script makes the protocol non-negotiable by
checking the DA's concession log against two hard rules, so the LLM cannot quietly
relax them:
Rule 1 — Concession requires evidence.
A finding may be conceded only if its rebuttal_score is >= 4 (the protocol's
"rebuttal directly / strongly addresses the attack with evidence"). Conceding
at rebuttal_score <= 3 is caving: the concession is REJECTED and the finding
is restored to "standing".
Rule 2 — No consecutive concessions (IRON RULE).
The DA may make at most one valid concession per two review rounds.
A concession in a round immediately following another conceding round is
REJECTED and the finding is restored to "standing".
A CRITICAL finding that is still standing after these rules (not resolved by the
revision, and not validly conceded) BLOCKS the "refinement accepted" decision —
the host must treat the iteration as REVERT, regardless of rubric scores.
Concession log schema (--log):
{
"rounds": [
{
"round": 1,
"findings": [
{
"id": "F1",
"severity": "critical" | "major" | "minor",
"attack": "Causal overclaiming: Sec 4 says X *causes* Y from corr only.",
"rebuttal_score": 2, # 1-5, DA's score of the author's rebuttal
"conceded": false, # did the DA drop the attack this round?
"resolved": false # was the underlying issue fixed in the revision?
}
]
}
]
}
Usage:
python concession_guard.py --log workspace/refinement/da_concessions.json
python concession_guard.py --log da_concessions.json --out guard_report.json
Exit codes:
0 CLEAR — no standing critical, no protocol violations → accept may proceed
1 BLOCK — a critical finding is still standing → host must REVERT this iteration
2 WARN — protocol violation(s) found but no critical blocked → DA must restate;
the rejected concession does not by itself force a revert
3 input / schema error
"""
import argparse
import json
import sys
VALID_SEVERITY = {"critical", "major", "minor"}
CONCESSION_MIN_REBUTTAL = 4 # rebuttal_score >= this to allow a concession
def analyze(rounds: list) -> dict:
violations = []
valid_concessions = []
standing_criticals = []
last_conceded_round = None # round index of the previous *valid* concession
for r in rounds:
rnum = r.get("round")
conceded_this_round = False
for fnd in r.get("findings", []):
fid = fnd.get("id", "?")
sev = fnd.get("severity", "minor")
conceded = bool(fnd.get("conceded", False))
resolved = bool(fnd.get("resolved", False))
score = fnd.get("rebuttal_score")
concession_valid = False
if conceded:
# Rule 1 — evidence threshold
if score is None or score < CONCESSION_MIN_REBUTTAL:
violations.append({
"round": rnum, "id": fid, "type": "caving",
"detail": f"conceded at rebuttal_score={score} "
f"(< {CONCESSION_MIN_REBUTTAL}); concession rejected",
})
# Rule 2 — no consecutive concessions
elif last_conceded_round is not None and rnum == last_conceded_round + 1:
violations.append({
"round": rnum, "id": fid, "type": "consecutive_concession",
"detail": f"concession in round {rnum} immediately follows a "
f"concession in round {last_conceded_round}; "
f"rejected (max one per two rounds)",
})
else:
concession_valid = True
conceded_this_round = True
valid_concessions.append({"round": rnum, "id": fid, "severity": sev})
# A critical is "standing" unless resolved OR validly conceded.
if sev == "critical" and not resolved and not concession_valid:
standing_criticals.append({
"round": rnum, "id": fid, "attack": fnd.get("attack", ""),
})
if conceded_this_round:
last_conceded_round = rnum
block = bool(standing_criticals)
if block:
action = "REVERT"
elif violations:
action = "DA_RESTATE"
else:
action = "PROCEED"
return {
"rounds_analyzed": len(rounds),
"valid_concessions": valid_concessions,
"violations": violations,
"standing_criticals": standing_criticals,
"block_accept": block,
"recommended_action": action,
}
def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--log", required=True, help="Concession log JSON path")
p.add_argument("--out", default=None, help="Optional path to write the guard report JSON")
args = p.parse_args()
try:
with open(args.log) as f:
log = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"ERROR: cannot read concession log: {e}", file=sys.stderr)
return 3
rounds = log.get("rounds")
if not isinstance(rounds, list) or not rounds:
print("ERROR: log['rounds'] must be a non-empty list", file=sys.stderr)
return 3
# Light schema validation — fail loudly rather than silently mis-classify.
for r in rounds:
for fnd in r.get("findings", []):
sev = fnd.get("severity", "minor")
if sev not in VALID_SEVERITY:
print(f"ERROR: finding {fnd.get('id','?')} has invalid severity "
f"'{sev}' (expected one of {sorted(VALID_SEVERITY)})", file=sys.stderr)
return 3
score = fnd.get("rebuttal_score")
if score is not None and not (1 <= score <= 5):
print(f"ERROR: finding {fnd.get('id','?')} rebuttal_score={score} "
f"out of range 1-5", file=sys.stderr)
return 3
report = analyze(rounds)
if args.out:
with open(args.out, "w") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
# Human-readable summary.
print(f"DA concession guard: {report['rounds_analyzed']} round(s) "
f"valid_concessions={len(report['valid_concessions'])} "
f"violations={len(report['violations'])} "
f"standing_criticals={len(report['standing_criticals'])}")
for v in report["violations"]:
print(f" VIOLATION [{v['type']}] round {v['round']} {v['id']}: {v['detail']}")
for c in report["standing_criticals"]:
print(f" STANDING CRITICAL round {c['round']} {c['id']}: {c['attack']}")
print(f" → recommended action: {report['recommended_action']}")
if report["block_accept"]:
return 1
if report["violations"]:
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
decision_band.py — Map a 0-100 overall score to a deterministic decision band.
The reviewer rubric (references/reviewer-rubric.md) produces a weighted 0-100
`overall_score` plus a free-form qualitative `decision`. That free-form label is
advisory; this script computes the *canonical* band the refinement loop reasons
about, so the band is reproducible and never drifts from the number it claims to
summarize.
Default bands (override with flags):
>= 80 Accept
65 .. 79 Minor Revision
50 .. 64 Major Revision
< 50 Reject
The bands give the loop an *absolute* quality target to complement the
*relative* accept/revert delta logic in score_delta.py: a paper can keep
improving relative to its previous self yet still sit in "Major Revision", and
conversely the loop can stop once it reaches "Accept" rather than burning
iterations chasing marginal gains.
Usage:
python decision_band.py --score 74.6
python decision_band.py --score-json workspace/refinement/iter2/score.json
python decision_band.py --score 81 --accept-min 80 --minor-min 65 --major-min 50
Exit codes:
0 band computed (always, on valid input)
2 usage / input error
"""
import argparse
import json
import sys
DEFAULT_ACCEPT_MIN = 80
DEFAULT_MINOR_MIN = 65
DEFAULT_MAJOR_MIN = 50
ACCEPT = "Accept"
MINOR = "Minor Revision"
MAJOR = "Major Revision"
REJECT = "Reject"
# Ordered worst -> best, so callers can compare band strength numerically.
BAND_RANK = {REJECT: 0, MAJOR: 1, MINOR: 2, ACCEPT: 3}
def band_for(score: float,
accept_min: float = DEFAULT_ACCEPT_MIN,
minor_min: float = DEFAULT_MINOR_MIN,
major_min: float = DEFAULT_MAJOR_MIN) -> str:
"""Return the decision band for an overall score. Importable by other scripts."""
if score >= accept_min:
return ACCEPT
if score >= minor_min:
return MINOR
if score >= major_min:
return MAJOR
return REJECT
def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
src = p.add_mutually_exclusive_group(required=True)
src.add_argument("--score", type=float, help="Overall score 0-100")
src.add_argument("--score-json", help="Path to a score.json with an overall_score field")
p.add_argument("--accept-min", type=float, default=DEFAULT_ACCEPT_MIN)
p.add_argument("--minor-min", type=float, default=DEFAULT_MINOR_MIN)
p.add_argument("--major-min", type=float, default=DEFAULT_MAJOR_MIN)
args = p.parse_args()
if not (args.accept_min > args.minor_min > args.major_min):
print("ERROR: thresholds must satisfy accept-min > minor-min > major-min",
file=sys.stderr)
return 2
if args.score is not None:
score = args.score
else:
try:
with open(args.score_json) as f:
data = json.load(f)
score = float(data["overall_score"])
except (OSError, json.JSONDecodeError, KeyError, ValueError, TypeError) as e:
print(f"ERROR: cannot read overall_score from {args.score_json}: {e}",
file=sys.stderr)
return 2
band = band_for(score, args.accept_min, args.minor_min, args.major_min)
out = {
"overall_score": score,
"decision_band": band,
"band_rank": BAND_RANK[band],
"thresholds": {
"accept_min": args.accept_min,
"minor_min": args.minor_min,
"major_min": args.major_min,
},
}
print(json.dumps(out, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
score_delta.py — Apply the PaperOrchestra refinement halt rules from two
score JSONs.
Encodes the halt rules from arXiv:2604.05018 §4 Step 5:
- ACCEPT if curr.overall > prev.overall
- ACCEPT if curr.overall == prev.overall AND net sub-axis delta >= 0
- REVERT (overall_decreased) if curr.overall < prev.overall
- REVERT (tied_negative_subaxis) if curr.overall == prev.overall AND
net sub-axis delta < 0
Additionally encodes the plateau early-stop rule (not in the original paper
but added to match its cost budget of ~5-7 LLM calls):
- HALT_PLATEAU if the improvement is accepted but overall_delta is below
--plateau-threshold for --plateau-streak or more consecutive iterations.
Exit code 4. The loop should stop — further iterations are unlikely to
yield meaningful gains.
And the decision-band target halt (see decision_band.py):
- HALT_TARGET_MET if an accepted iteration reaches the "Accept" band
(overall >= --accept-threshold, default 80). Exit code 5. The paper now
clears the acceptance bar, so the loop stops on the current draft rather
than risk regressing it chasing marginal gains. Takes precedence over the
plateau halt. Disable with --no-target-halt.
Every output JSON also carries the prev/curr decision band (Accept / Minor
Revision / Major Revision / Reject) so the run report can show an absolute
quality trajectory, not just relative deltas.
Exit codes:
0 ACCEPT (improved or tied non-negative; below Accept band; no plateau)
1 REVERT (overall decreased)
2 REVERT (tied with negative sub-axis delta)
3 argument or input error
4 HALT_PLATEAU (accepted but diminishing returns detected)
5 HALT_TARGET_MET (accepted and reached the Accept band)
Score JSON shape (see references/reviewer-rubric.md):
{
"axis_scores": {
"scientific_depth": {"score": 65, ...},
"technical_execution": {"score": 70, ...},
"logical_flow": {"score": 60, ...},
"writing_clarity": {"score": 55, ...},
"evidence_presentation":{"score": 72, ...},
"academic_style": {"score": 68, ...}
},
"overall_score": 64.5,
...
}
Usage:
python score_delta.py --prev iter0/score.json --curr iter1/score.json
python score_delta.py --prev iter2/score.json --curr iter3/score.json \\
--plateau-threshold 1.0 --plateau-streak 2 --consecutive-small 2
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from decision_band import band_for, BAND_RANK # noqa: E402
AXES = [
"scientific_depth",
"technical_execution",
"logical_flow",
"writing_clarity",
"evidence_presentation",
"academic_style",
]
DEFAULT_PLATEAU_THRESHOLD = 1.0 # points
DEFAULT_PLATEAU_STREAK = 3 # consecutive iterations below threshold → halt
def load(path: str) -> dict:
with open(path) as f:
return json.load(f)
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--prev", required=True, help="Score JSON from previous accepted iteration")
p.add_argument("--curr", required=True, help="Score JSON from just-completed iteration")
p.add_argument(
"--plateau-threshold", type=float, default=DEFAULT_PLATEAU_THRESHOLD,
metavar="POINTS",
help=f"Minimum overall_delta to not count as a 'small' improvement "
f"(default: {DEFAULT_PLATEAU_THRESHOLD})",
)
p.add_argument(
"--plateau-streak", type=int, default=DEFAULT_PLATEAU_STREAK,
metavar="N",
help=f"Number of consecutive small improvements before HALT_PLATEAU "
f"(default: {DEFAULT_PLATEAU_STREAK})",
)
p.add_argument(
"--consecutive-small", type=int, default=0,
metavar="N",
help="Number of consecutive small-delta accepted iterations so far "
"(maintained by the calling loop; default: 0)",
)
p.add_argument(
"--accept-threshold", type=float, default=80.0, metavar="SCORE",
help="Overall score at/above which the paper is in the Accept band; "
"an accepted iteration here triggers HALT_TARGET_MET (default: 80)",
)
p.add_argument(
"--no-target-halt", action="store_true",
help="Do not halt on reaching the Accept band (still reports the band)",
)
args = p.parse_args()
try:
prev = load(args.prev)
curr = load(args.curr)
except (OSError, json.JSONDecodeError) as e:
print(f"ERROR: failed to load score JSONs: {e}", file=sys.stderr)
return 3
p_overall = float(prev.get("overall_score", 0))
c_overall = float(curr.get("overall_score", 0))
overall_delta = c_overall - p_overall
p_axes = prev.get("axis_scores") or {}
c_axes = curr.get("axis_scores") or {}
deltas: dict[str, float] = {}
for ax in AXES:
ps = float((p_axes.get(ax) or {}).get("score", 0))
cs = float((c_axes.get(ax) or {}).get("score", 0))
deltas[ax] = cs - ps
net_subaxis = sum(deltas.values())
# --- Primary accept/revert decision ---
if c_overall > p_overall:
decision = "ACCEPT_IMPROVED"
exit_code = 0
elif c_overall == p_overall:
if net_subaxis >= 0:
decision = "ACCEPT_TIED_NON_NEGATIVE"
exit_code = 0
else:
decision = "REVERT_TIED_NEGATIVE_SUBAXIS"
exit_code = 2
else:
decision = "REVERT_OVERALL_DECREASED"
exit_code = 1
# --- Decision bands (absolute quality, independent of the delta) ---
band_prev = band_for(p_overall, args.accept_threshold)
band_curr = band_for(c_overall, args.accept_threshold)
# --- Plateau early-stop (only applies to accepted iterations) ---
is_small_delta = overall_delta < args.plateau_threshold
new_consecutive_small = (args.consecutive_small + 1) if is_small_delta else 0
plateau_triggered = False
# --- Target-met halt takes precedence over plateau ---
target_met = False
if exit_code == 0 and not args.no_target_halt and c_overall >= args.accept_threshold:
decision = "HALT_TARGET_MET"
exit_code = 5
target_met = True
elif exit_code == 0 and new_consecutive_small >= args.plateau_streak:
decision = "HALT_PLATEAU"
exit_code = 4
plateau_triggered = True
out = {
"decision": decision,
"exit_code": exit_code,
"overall_prev": p_overall,
"overall_curr": c_overall,
"overall_delta": overall_delta,
"decision_band_prev": band_prev,
"decision_band_curr": band_curr,
"band_improved": BAND_RANK[band_curr] > BAND_RANK[band_prev],
"target_met": target_met,
"accept_threshold": args.accept_threshold,
"subaxis_deltas": deltas,
"net_subaxis": net_subaxis,
"is_small_delta": is_small_delta,
"consecutive_small": new_consecutive_small,
"plateau_threshold": args.plateau_threshold,
"plateau_streak": args.plateau_streak,
"plateau_triggered": plateau_triggered,
}
print(json.dumps(out, indent=2))
return exit_code
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
score_trajectory.py — Track per-dimension score deltas across refinement iterations
and detect regression or plateau conditions.
Exit codes:
0 — OK: no regression or plateau detected
1 — REGRESSION or PLATEAU: issue detected (see output for details)
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
DIMENSIONS = [
"scientific_depth",
"technical_execution",
"logical_flow",
"writing_clarity",
"evidence_presentation",
"academic_style",
]
COLUMN_WIDTH = 22
def load_scores(scores_dir: str) -> list:
"""
Load all iterN/scores.json files from scores_dir in order.
Returns list of (iter_number, scores_dict) sorted by iter_number.
"""
scores_dir_path = Path(scores_dir)
if not scores_dir_path.is_dir():
print(f"[ERROR] scores-dir not found: {scores_dir}", file=sys.stderr)
sys.exit(1)
entries = []
for item in scores_dir_path.iterdir():
match = re.match(r"^iter(\d+)$", item.name)
if match and item.is_dir():
scores_file = item / "scores.json"
if scores_file.exists():
try:
with open(scores_file, "r", encoding="utf-8") as f:
data = json.load(f)
entries.append((int(match.group(1)), data))
except (json.JSONDecodeError, IOError) as e:
print(
f"[WARN] Could not load {scores_file}: {e}",
file=sys.stderr,
)
entries.sort(key=lambda x: x[0])
return entries
def compute_overall(scores: dict) -> float:
"""Compute mean of all 6 dimensions."""
values = [scores.get(d, 0) for d in DIMENSIONS]
return sum(values) / len(values)
def compute_deltas(prev: dict, curr: dict) -> dict:
"""Compute per-dimension delta (curr - prev)."""
return {d: curr.get(d, 0) - prev.get(d, 0) for d in DIMENSIONS}
def print_score_table(entries: list):
"""Print a formatted table of scores per dimension across iterations."""
iter_labels = [f"iter{n}" for n, _ in entries]
# Header
header = f"{'Dimension':<{COLUMN_WIDTH}}" + "".join(
f"{label:>10}" for label in iter_labels
)
print(header)
print("-" * len(header))
# Rows per dimension
for dim in DIMENSIONS:
row = f"{dim:<{COLUMN_WIDTH}}"
for _, scores in entries:
val = scores.get(dim, "N/A")
if isinstance(val, (int, float)):
row += f"{val:>10.1f}"
else:
row += f"{'N/A':>10}"
print(row)
# Overall row
print("-" * len(header))
overall_row = f"{'OVERALL':<{COLUMN_WIDTH}}"
for _, scores in entries:
overall = compute_overall(scores)
overall_row += f"{overall:>10.1f}"
print(overall_row)
print()
# Delta rows (if more than 1 iteration)
if len(entries) > 1:
print("Per-dimension deltas (current vs previous iteration):")
delta_header = f"{'Dimension':<{COLUMN_WIDTH}}" + "".join(
f"{f'Δ{iter_labels[i]}':>10}" for i in range(1, len(iter_labels))
)
print(delta_header)
print("-" * len(delta_header))
for dim in DIMENSIONS:
row = f"{dim:<{COLUMN_WIDTH}}"
for i in range(1, len(entries)):
prev_scores = entries[i - 1][1]
curr_scores = entries[i][1]
delta = curr_scores.get(dim, 0) - prev_scores.get(dim, 0)
sign = "+" if delta > 0 else ""
row += f"{sign}{delta:>9.1f}"
print(row)
# Overall delta row
overall_delta_row = f"{'OVERALL Δ':<{COLUMN_WIDTH}}"
for i in range(1, len(entries)):
prev_overall = compute_overall(entries[i - 1][1])
curr_overall = compute_overall(entries[i][1])
delta = curr_overall - prev_overall
sign = "+" if delta > 0 else ""
overall_delta_row += f"{sign}{delta:>9.1f}"
print("-" * len(delta_header))
print(overall_delta_row)
print()
def main():
parser = argparse.ArgumentParser(
description=(
"Track score trajectory across refinement iterations "
"and detect regression or plateau."
)
)
parser.add_argument(
"--scores-dir",
required=True,
help="Directory containing iterN/ subdirectories with scores.json files",
)
parser.add_argument(
"--regression-threshold",
type=float,
default=-3.0,
help=(
"Minimum allowed per-dimension delta in the latest iteration. "
"If any dimension drops more than this, trigger REGRESSION. "
"(default: -3, i.e., flag if any dimension drops by more than 3 points)"
),
)
parser.add_argument(
"--report-path",
default=None,
help="Optional path to write a JSON report",
)
args = parser.parse_args()
entries = load_scores(args.scores_dir)
if not entries:
print("[ERROR] No iterN/scores.json files found in scores-dir.", file=sys.stderr)
sys.exit(1)
if len(entries) == 1:
print(f"Only one iteration found (iter{entries[0][0]}). No trajectory to analyze.")
print_score_table(entries)
if args.report_path:
report = {
"status": "ok",
"latest_deltas": {},
"history": [{"iter": entries[0][0], "scores": entries[0][1]}],
}
with open(args.report_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
sys.exit(0)
print_score_table(entries)
# Analyze latest iteration for regression
latest_idx = len(entries) - 1
prev_scores = entries[latest_idx - 1][1]
curr_scores = entries[latest_idx][1]
latest_deltas = compute_deltas(prev_scores, curr_scores)
latest_overall_delta = compute_overall(curr_scores) - compute_overall(prev_scores)
# Check for REGRESSION in latest iteration
regression_dims = [
dim
for dim, delta in latest_deltas.items()
if delta < args.regression_threshold
]
# Check for PLATEAU: overall delta < 1.0 for 2+ consecutive iterations
plateau_count = 0
for i in range(1, len(entries)):
prev_overall = compute_overall(entries[i - 1][1])
curr_overall = compute_overall(entries[i][1])
if abs(curr_overall - prev_overall) < 1.0:
plateau_count += 1
else:
plateau_count = 0 # reset on non-plateau iteration
plateau_detected = plateau_count >= 2
# Build history for report
history = []
for i, (iter_num, scores) in enumerate(entries):
entry = {"iter": iter_num, "scores": scores, "overall": compute_overall(scores)}
if i > 0:
prev = entries[i - 1][1]
entry["deltas"] = compute_deltas(prev, scores)
entry["overall_delta"] = compute_overall(scores) - compute_overall(prev)
history.append(entry)
# Determine status
status = "ok"
exit_code = 0
messages = []
if regression_dims:
status = "regression"
exit_code = 1
for dim in regression_dims:
delta = latest_deltas[dim]
messages.append(
f"REGRESSION: {dim} dropped {delta:.1f} points in "
f"iter{entries[latest_idx][0]} "
f"(threshold: {args.regression_threshold})"
)
if plateau_detected and status == "ok":
status = "plateau"
exit_code = 1
messages.append(
f"PLATEAU: overall score change < 1.0 for {plateau_count} "
f"consecutive iterations — further refinement unlikely to yield gains"
)
# Print status
if messages:
for msg in messages:
print(f"[{status.upper()}] {msg}")
print()
else:
print(f"[OK] No regression or plateau detected in latest iteration.")
print(
f" Overall delta iter{entries[latest_idx-1][0]} → "
f"iter{entries[latest_idx][0]}: "
f"{latest_overall_delta:+.1f}"
)
# Write report if requested
if args.report_path:
report = {
"status": status,
"latest_deltas": latest_deltas,
"history": history,
}
report_path = Path(args.report_path)
report_path.parent.mkdir(parents=True, exist_ok=True)
with open(report_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
print(f"Report written to: {args.report_path}")
sys.exit(exit_code)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
snapshot.py — Copy a paper.tex (and optionally paper.pdf) into a refinement
iteration directory, so reverts are real, not symbolic.
The PaperOrchestra refinement halt rules require the loop to roll back to
the previous iteration on overall-score decrease or tied negative sub-axis
delta. To do that physically, every iteration's draft must be preserved.
Usage:
python snapshot.py --src paper.tex --dst iter2/
python snapshot.py --src paper.tex --src-pdf paper.pdf --dst iter2/
"""
import argparse
import os
import shutil
import sys
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--src", required=True, help="source paper.tex path")
p.add_argument("--src-pdf", help="optional source paper.pdf path")
p.add_argument("--dst", required=True, help="destination iteration directory")
args = p.parse_args()
if not os.path.isfile(args.src):
print(f"ERROR: {args.src} not found", file=sys.stderr)
return 1
os.makedirs(args.dst, exist_ok=True)
dst_tex = os.path.join(args.dst, "paper.tex")
shutil.copy2(args.src, dst_tex)
print(f"OK: snapshot {args.src} → {dst_tex}")
if args.src_pdf:
if not os.path.isfile(args.src_pdf):
print(f"WARN: {args.src_pdf} not found, skipping PDF snapshot",
file=sys.stderr)
else:
dst_pdf = os.path.join(args.dst, "paper.pdf")
shutil.copy2(args.src_pdf, dst_pdf)
print(f"OK: snapshot {args.src_pdf} → {dst_pdf}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
update_critique_memory.py — Build and maintain a structured critique memory
across content-refinement iterations (AutoSci-inspired persistent context).
Reads the refinement worklog.json plus the current iteration's review.json
and produces/updates workspace/refinement/critique_memory.json, which tracks:
- persistent_issues : weaknesses flagged in 2+ iterations without resolution
- resolved_issues : weaknesses addressed and not re-flagged
- da_critical_unresolved : Devil's Advocate CRITICAL findings still open
- focus_on : short prompts the next reviewer should prioritise
- do_not_reflag : resolved issues the next reviewer must NOT re-flag
The content-refinement-agent passes critique_memory.json to the reviewer
prompt at the start of every iteration so the reviewer has memory of prior
rounds.
Exit codes:
0 OK
1 Input error (missing file, bad JSON)
Usage:
python update_critique_memory.py \\
--worklog workspace/refinement/worklog.json \\
--review workspace/refinement/iter2/review.json \\
--iter 2 \\
--out workspace/refinement/critique_memory.json
"""
import argparse
import json
import os
import sys
from difflib import SequenceMatcher
# ── helpers ─────────────────────────────────────────────────────────────────
def load_json(path: str) -> dict | list:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
print(f"ERROR: file not found: {path}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"ERROR: invalid JSON in {path}: {e}", file=sys.stderr)
sys.exit(1)
def _similarity(a: str, b: str) -> float:
"""Return a 0–1 similarity ratio between two strings (case-insensitive)."""
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
def _find_match(finding: str, pool: list[str], threshold: float = 0.55) -> str | None:
"""Return the closest string in pool if similarity >= threshold, else None."""
best, best_score = None, 0.0
for item in pool:
s = _similarity(finding, item)
if s > best_score:
best, best_score = item, s
return best if best_score >= threshold else None
def _extract_weaknesses(review: dict) -> list[dict]:
"""
Extract weakness entries from a review.json.
Supports both flat list and dict-with-axis variants:
{"weaknesses": ["...", ...]}
{"weaknesses": [{"text": "...", "axis": "...", "da_critical": bool}]}
"""
raw = review.get("weaknesses") or []
out = []
for item in raw:
if isinstance(item, str):
out.append({"text": item, "axis": None, "da_critical": False})
elif isinstance(item, dict):
out.append({
"text": item.get("text") or item.get("finding") or str(item),
"axis": item.get("axis"),
"da_critical": bool(item.get("da_critical", False)),
})
# Also pick up top-level da_critical findings from worklog entry format
for item in review.get("da_critical_findings") or []:
text = item if isinstance(item, str) else item.get("finding", str(item))
out.append({"text": text, "axis": None, "da_critical": True})
return out
def _extract_addressed(review: dict) -> list[str]:
"""
Extract weaknesses that the revision addressed (from the worklog_entry /
actions block emitted by the revision agent).
"""
addressed = []
for field in ("addressed_weaknesses", "actions_taken"):
for item in review.get(field) or []:
if isinstance(item, str):
addressed.append(item)
elif isinstance(item, dict):
addressed.append(item.get("weakness") or item.get("action") or str(item))
return addressed
# ── core logic ───────────────────────────────────────────────────────────────
def build_memory(worklog: dict, current_review: dict, current_iter: int) -> dict:
"""
Produce an updated critique_memory dict.
Algorithm:
1. Collect all weakness texts across all previous iterations from worklog.
2. Collect all "addressed" texts from revision agents in worklog.
3. Merge with current_review weaknesses.
4. A weakness is "persistent" if it (or a near-duplicate) was flagged in
≥2 distinct iterations and not matched by any "addressed" text.
5. A weakness is "resolved" if it was flagged before and matched by an
"addressed" text in a subsequent iteration, and not re-flagged since.
"""
# ── build per-iter weakness and addressed maps from worklog ─────────────
iter_weaknesses: dict[int, list[dict]] = {} # iter → [{text, axis, da_critical}]
iter_addressed: dict[int, list[str]] = {} # iter → [addressed text]
for entry in worklog.get("iterations", []):
i = entry.get("iter", -1)
review_block = entry.get("review") or {}
actions_block = entry.get("actions") or {}
# weaknesses from the review sub-block
iter_weaknesses[i] = _extract_weaknesses(review_block)
# addressed texts from the actions sub-block
iter_addressed[i] = _extract_addressed(actions_block)
# Add current iteration's weaknesses (not yet in worklog)
iter_weaknesses[current_iter] = _extract_weaknesses(current_review)
# ── collect all unique finding texts seen so far ─────────────────────────
# Map canonical_text → {first_iter, iter_set, axis, da_critical}
canonical: dict[str, dict] = {}
all_iters_sorted = sorted(iter_weaknesses.keys())
for i in all_iters_sorted:
for w in iter_weaknesses[i]:
text = w["text"].strip()
if not text:
continue
existing = _find_match(text, list(canonical.keys()))
if existing:
canonical[existing]["iter_set"].add(i)
if w["axis"]:
canonical[existing]["axis"] = w["axis"]
if w["da_critical"]:
canonical[existing]["da_critical"] = True
else:
canonical[text] = {
"first_iter": i,
"iter_set": {i},
"axis": w["axis"],
"da_critical": w["da_critical"],
}
# ── collect all addressed texts across all iterations ───────────────────
all_addressed: list[str] = []
for i in sorted(iter_addressed.keys()):
all_addressed.extend(iter_addressed[i])
# ── classify each canonical finding ─────────────────────────────────────
persistent_issues: list[dict] = []
resolved_issues: list[dict] = []
da_critical_unresolved: list[dict] = []
for text, meta in canonical.items():
times_flagged = len(meta["iter_set"])
was_addressed = _find_match(text, all_addressed) is not None
still_open = not was_addressed
if meta["da_critical"] and still_open:
da_critical_unresolved.append({
"finding": text,
"first_flagged_iter": meta["first_iter"],
"times_flagged": times_flagged,
"axis": meta["axis"],
})
elif times_flagged >= 2 and still_open:
persistent_issues.append({
"finding": text,
"first_flagged_iter": meta["first_iter"],
"times_flagged": times_flagged,
"axis": meta["axis"],
})
elif was_addressed and not still_open:
resolved_issues.append({
"finding": text,
"first_flagged_iter": meta["first_iter"],
"resolved_by_iter": max(
(i for i in iter_addressed if _find_match(text, iter_addressed[i])),
default=current_iter,
),
"axis": meta["axis"],
})
# Sort persistent by times_flagged DESC, then first_iter ASC
persistent_issues.sort(key=lambda x: (-x["times_flagged"], x["first_flagged_iter"]))
# ── build focus/do-not-reflag prompt fragments ───────────────────────────
focus_on = [
f"[iter {p['first_flagged_iter']}+, {p['times_flagged']}x] {p['finding']}"
for p in persistent_issues[:5]
]
focus_on = [
f"[DA-CRITICAL, iter {d['first_flagged_iter']}+] {d['finding']}"
for d in da_critical_unresolved
] + focus_on
do_not_reflag = [
f"{r['finding']}"
for r in resolved_issues[-10:] # cap at 10 to keep prompt size reasonable
]
return {
"current_iter": current_iter,
"persistent_issues": persistent_issues,
"resolved_issues": resolved_issues,
"da_critical_unresolved": da_critical_unresolved,
"focus_on": focus_on,
"do_not_reflag": do_not_reflag,
"_stats": {
"total_unique_findings": len(canonical),
"persistent_count": len(persistent_issues),
"resolved_count": len(resolved_issues),
"da_critical_open": len(da_critical_unresolved),
},
}
# ── main ─────────────────────────────────────────────────────────────────────
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--worklog", required=True, help="Path to worklog.json")
p.add_argument("--review", required=True, help="Path to current iter's review.json")
p.add_argument("--iter", type=int, required=True, help="Current iteration number")
p.add_argument("--out", required=True, help="Output path for critique_memory.json")
args = p.parse_args()
worklog = load_json(args.worklog) if os.path.exists(args.worklog) else {"iterations": []}
current_review = load_json(args.review)
memory = build_memory(worklog, current_review, args.iter)
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
with open(args.out, "w") as f:
json.dump(memory, f, indent=2, ensure_ascii=False)
stats = memory["_stats"]
print(f"OK: critique_memory.json updated for iter {args.iter}")
print(f" persistent: {stats['persistent_count']} "
f"resolved: {stats['resolved_count']} "
f"da_critical_open: {stats['da_critical_open']}")
if memory["focus_on"]:
print(" focus_on:")
for line in memory["focus_on"]:
print(f" - {line}")
if memory["do_not_reflag"]:
print(f" do_not_reflag: {len(memory['do_not_reflag'])} resolved issue(s) suppressed")
return 0
if __name__ == "__main__":
sys.exit(main())