AFlow on AgentCanvas — paper analysis + aflow port

A two-part document. Section 1 dissects Zhang et al. 2024 “AFlow: Automating Agentic Workflow Generation” (ICLR 2025 Oral) — how optimizer.py's outer round loop, score-softmax parent sampling, per-parent experience filtering, and validation-rounds averaging actually work, with the original source annotated. Section 2 documents the aflow AgentCanvas port (namespace .claude/commands/architect/aflow/) — 5-skill layout reusing most of adas-subagent's substrate plus the 4 algorithmic deltas AFlow's MCTS-flavoured search demands.

section 1 upstream paper / code analysis   section 2 aflow port   scope AFlow-style — search policy ported, search space is free-form (no operator set)

One framing correction up front. The paper sells AFlow as “MCTS over workflows” and most secondary summaries (including ours, earlier) carry that label. The implementation in optimizer.py is something narrower and worth naming precisely: score-softmax + uniform-mix sampling over a flat round list, with per-parent anti-replay filtering on modification descriptions. There is no tree traversal, no UCB statistic, no visit count, no backprop. The “tree” lives only in each round's experience.json as a father_node field the optimizer writes but never reads as a tree. Treat the MCTS framing as rhetorical; treat the rest of this Section 1 as what AFlow really runs.

Section 1 — Upstream paper analysis (Zhang et al. 2024)

How the original AFlow paper's optimize() and _optimize_graph() functions drive one round (source: third_party/AFlow/scripts/optimizer.py:71–198 and the parent-sampling helper at scripts/optimizer_utils/data_utils.py:61–109). The orange parent-selection block + single LLM call is the load-bearing piece — everything else is bookkeeping. Annotations below the SVG link each diagram region to the exact upstream line.

Diagram ↔ code map:

round_1 baseline graph.py + prompt.py evaluate_graph (initial=True) validation_rounds = 5 avg_score → results.json append results.json [ {round:1, score: x} ] for opt_round in range(max_rounds = 20): on Exception: max_retries=1, score=None, round skipped while True — resample parent until modification is novel (per-parent anti-replay) get_top_rounds top-K rounds by score select_round λ⋅uniform + (1−λ)⋅softmax α=0.2, λ=0.3 build optimize_prompt {experience, score, graph, prompt, operators, log×3} LLM (single call) call_with_format(GraphOptimize) → {modification, graph, prompt} single-turn prompt, no shared msg_list across rounds — cf. ADAS's 3-call Reflexion thread on FormatError → regex-extract <modification> / <graph> / <prompt> check_modification: already on parent's success/failure list? yes — regenerate re-sample parent and re-call LLM no write_graph_files round_{N+1}: graph.py + prompt.py evaluate_graph (initial=False) validation_rounds=5 → avg_score update_experience {father_node, modification, before, after, succeed} check_convergence: top-3 avg stable for 5 consecutive rounds? yes break outer loop (converged) no — next opt_round (self.round += 1; results.json append) orange box = LLM call site / parent-sampling decision — the load-bearing pieces of AFlow's algorithm
LLM call / softmax-mix parent sampling (orange) computation archive / decision / persistent state failure / fallback

Annotated source — _optimize_graph()

Below is the verbatim _optimize_graph() function from third_party/AFlow/scripts/optimizer.py:120–198, plus the outer-loop frame from :71–118, with right-side annotations tying each region back to a box / arrow in the diagram above. Yes — the diagram is a faithful structural translation; every diagram element has a corresponding code region marked here.

 71  def optimize(self, mode: OptimizerType = "Graph"):
 72      if mode == "Test":
 73          ...
 80      for opt_round in range(self.max_rounds):                       ◂— OUTER DASHED BOX (for opt_round in range(max_rounds))
 81          loop = asyncio.new_event_loop()
 82          asyncio.set_event_loop(loop)
 83
 84          retry_count = 0
 85          max_retries = 1                                            ◂— only 1 attempt; not a retry budget despite the variable name
 86
 87          while retry_count < max_retries:
 88              try:
 89                  score = loop.run_until_complete(self._optimize_graph())
 90                  break
 91              except Exception as e:                                 ◂— ANY Python exception during the round
 92                  retry_count += 1
 93                  logger.info(f"Error occurred: {e}. Retrying... ...")
 94                  if retry_count == max_retries:
 95                      logger.info("Max retries reached. Moving to next round.")
 96                      score = None                                                score=None, round produces no child workflow
 97                  wait_time = 5 * retry_count
 98                  time.sleep(wait_time)
 99              ...
105          self.round += 1
106          logger.info(f"Score for round {self.round}: {score}")
107
108          converged, convergence_round, final_round = self.convergence_utils.check_convergence(top_k=3)
                                                                       ◂— DIAMOND: check_convergence (top_k=3, z=0, consecutive_rounds=5)
109
110          if converged and self.check_convergence:
111              ...                                                    ◂— BOX: break outer loop (converged)
116              break
117
118          time.sleep(5)
120  async def _optimize_graph(self):
121      validation_n = self.validation_rounds                          # default 5
122      graph_path = f"{self.root_path}/workflows"
123      data = self.data_utils.load_results(graph_path)
124
125      if self.round == 1:                                            ◂— TOP ROW: first-round init
126          directory = self.graph_utils.create_round_directory(graph_path, self.round)
127          self.graph = self.graph_utils.load_graph(self.round, graph_path)
128          avg_score = await self.evaluation_utils.evaluate_graph(         baseline scored with validation_rounds=5
129              self, directory, validation_n, data, initial=True)
130
131      # Create a loop until the generated graph meets the check conditions
132      while True:                                                    ◂— INNER ORANGE DASHED BOX (anti-replay sampling loop)
133          directory = self.graph_utils.create_round_directory(graph_path, self.round + 1)
134
135          top_rounds = self.data_utils.get_top_rounds(self.sample)   ◂— BOX: get_top_rounds (top-K by score)
136          sample = self.data_utils.select_round(top_rounds)          ◂— BOX (orange): select_round
                                                                           softmax(α=0.2)+uniform(λ=0.3) mix; np.random.choice on top-K
137
138          prompt, graph_load = self.graph_utils.read_graph_files(    ◂— BOX: build optimize_prompt (read parent files)
139              sample["round"], graph_path)
140          graph = self.graph_utils.extract_solve_graph(graph_load)
141
142          processed_experience = self.experience_utils.load_experience()
143          experience = self.experience_utils.format_experience(           parent's success+failure history injected as natural language
144              processed_experience, sample["round"])
145
146          operator_description = self.graph_utils.load_operators_description(self.operators)
147          log_data = self.data_utils.load_log(sample["round"])            3 random failure samples from parent's log.json
148
149          graph_optimize_prompt = self.graph_utils.create_graph_optimize_prompt(
150              experience, sample["score"], graph[0], prompt, operator_description, self.type, log_data
151          )
152
153          try:
154              graph_formatter = XmlFormatter.from_model(GraphOptimize)
155
156              response = await self.optimize_llm.call_with_format(   ◂— BOX (orange): LLM single call
157                  graph_optimize_prompt,                                  XmlFormatter parses <modification> <graph> <prompt> tags
158                  graph_formatter)
159              ...
163              logger.info(f"Graph optimization response received successfully")
164          except FormatError as e:                                   ◂— RED FALLBACK: schema validation failed
165              logger.error(f"Format error in graph optimization: {str(e)}")
166              raw_response = await self.optimize_llm(graph_optimize_prompt)
167
168              response = self._extract_fields_from_response(raw_response)
                                                                            regex-extract <modification> <graph> <prompt> from raw text
172              if not response:
173                  logger.error("Failed to extract fields from raw response, retrying...")
174                  continue                                                extraction failed → resample parent + re-call LLM
175
176          # Check if the modification meets the conditions
177          check = self.experience_utils.check_modification(          ◂— DIAMOND: check_modification
178              processed_experience, response["modification"],            duplicate of parent's success OR failure list? → False = regenerate
179              sample["round"]
180          )
181
182          if check:                                                  ◂— DIAMOND NO branch: exit inner orange box
183              break
184                                                                          (DIAMOND YES branch: implicit `continue` — re-sample parent + re-call LLM)
185
186      # Save the graph and evaluate
187      self.graph_utils.write_graph_files(directory, response, self.round + 1, self.dataset)
                                                                       ◂— BOX: write_graph_files (round_{N+1} dir)
188
189      experience = self.experience_utils.create_experience_data(sample, response["modification"])
190
191      self.graph = self.graph_utils.load_graph(self.round + 1, graph_path)
192
193      logger.info(directory)
194
195      avg_score = await self.evaluation_utils.evaluate_graph(        ◂— BOX: evaluate_graph (validation_rounds=5, initial=False)
196          self, directory, validation_n, data, initial=False)
197
198      self.experience_utils.update_experience(directory, experience, avg_score)
                                                                       ◂— BOX: update_experience (experience.json: succeed=bool(after>before))
199
200      return avg_score

The parent-sampling helper — _compute_probabilities

This is the algorithmic heart of AFlow's “tree” search and worth quoting in full. The function decides which prior round is the parent of the next child workflow.

 14  class DataUtils:
 15
 16      DEFAULT_ALPHA = 0.2                                            # very flat softmax over scores
 17      DEFAULT_LAMBDA = 0.3                                           # 30% pure uniform mix → forced exploration
 18      DEFAULT_LOG_SAMPLES = 3                                        # 3 failure samples injected as context
 ...
 61      def select_round(self, items: List[Dict]) -> Dict:
 ...
 66          sorted_items = sorted(items, key=lambda x: x["score"], reverse=True)
 67          scores = [item["score"] * 100 for item in sorted_items]
 68
 69          probabilities = self._compute_probabilities(scores)
 ...
 73          selected_index = np.random.choice(len(sorted_items), p=probabilities)
 ...
 78      def _compute_probabilities(self, scores, alpha=0.2, lambda_=0.3):
 ...
 85          scores = np.array(scores, dtype=np.float64)
 86          n = len(scores)
 ...
 91          uniform_prob = np.full(n, 1.0 / n, dtype=np.float64)       ◂— uniform component
 92
 93          max_score = np.max(scores)
 94          shifted_scores = scores - max_score
 95          exp_weights = np.exp(alpha * shifted_scores)               ◂— softmax(α⋅score) numerator
 96
 97          sum_exp_weights = np.sum(exp_weights)
 ...
101          score_prob = exp_weights / sum_exp_weights                 ◂— score-weighted component
102
103          mixed_prob = lambda_ * uniform_prob + (1 - lambda_) * score_prob
                                                                       ◂— final mix: 30% uniform + 70% softmax
104
 ...
109          return mixed_prob

Compare this against UCB1 (argmax_c [Q̂(c) + C⋅√(ln N(parent) / n(c))]): no visit count, no exploration bonus that decays with visits, no parent child statistic. AFlow's exploration term is a flat λ⋅uniform baseline that does not shrink — even after 100 rounds, every top-K round still has a 30/K floor on selection probability. This is qualitatively different behaviour from UCB, and the paper's MCTS framing should be read as analogical rather than literal.

Upstream invariants worth marking

These are the fidelity anchors any AgentCanvas port would need to preserve.

Section 2 below documents how the aflow AgentCanvas port preserves each of these invariants. Three design forks raised in the planning discussion are resolved inline: F1 mutation surface = free-form change (graph + prompt in one shot, no typed action enum) — realized 2026-05-20 by the implementer sub-agent editing the active_workspace/ overlay natively; the proposer hands over a prose {intent, targets} change spec (the earlier typed op DSL was the compromise of routing edits through a deterministic replayer); F2 parent-selection = faithful score-softmax + uniform-mix with α=0.2 / λ=0.3 against archive.jsonl; F3 validation-rounds budget = single-pass 100-ep eval with bootstrap-CI — the only forced adaptation, since 5×100 ep at our cost would be ~5–10 h per round. The bootstrap resampling captures the episode-sampling variance of the mean — it does NOT capture the run-to-run stochasticity (LLM temperature, nondeterministic agent behaviour across passes) that upstream's 5 independent passes average out. These are different variance components; the bootstrap is a cost-forced approximation, not a statistical equivalent of the 5-repeat mean. This is not localized to evaluation cost: the single-pass score feeds select_round's softmax and check_convergence's stability test, both of which assume score is comparable across iters — so the unmodeled run-to-run variance can mis-rank a parent into the top-K, and (because per-round σ is 0 under one pass) collapses the convergence predicate to exact equality. F3 is therefore a soundness caveat on parent selection, not merely an eval shortcut — see Section 2 § “Two-tier evaluation”.

Section 2 — Our aflow implementation

A new namespace at .claude/commands/architect/aflow/ that ports AFlow's optimize() + _optimize_graph() control flow onto the AgentCanvas substrate. The cross-variant file/iter contract (run-dir layout, resolve protocol, {graph}.yaml schema, edit whitelist, eval-API endpoints) is shared with adas-subagent, adas v1 and myloop via ../_common/files-contract.md; aflow only declares what diverges from adas-subagent.

Five algorithmic changes vs adas-subagent — AFlow's per-parent-experience MCTS-flavoured search differs from ADAS's archive-injection meta-LLM in ways that affect the proposer's call shape, the loop's per-iter bookkeeping, the archive entry schema, and the evaluation tiering:

  1. Single LLM call per attempt, not 3-call Reflexion. adas-subagent's proposer threads three calls (propose / R1 / R2) through one msg_list. aflow's proposer makes one call_with_format(GraphOptimize) call per attempt; the conversation thread is the anti-replay retry loop — if check_modification rejects, the thread re-samples a parent and re-calls the LLM with a fresh optimize_prompt. Verbatim upstream optimizer.py:132–183.
  2. Parent selection at the head of each iter, not implicit “archive head”. adas-subagent always proposes from the archive head. aflow runs select_round(top_K) with p = λ⋅uniform + (1−λ)⋅softmax(α⋅score) against archive.jsonlany prior round can be the parent, including iter_0. The chosen parent_iter_id is recorded in proposal.md frontmatter so loop can restore the parent's active_workspace/ into .staging/iter_n/ before invoking implementer.
  3. Archive entry carries parent_iter_id + modification for per-parent anti-replay. adas-subagent's archive entry is {generation, iter_id, name, thought, graph_summary, diff_narrative, fitness}. aflow adds parent_iter_id (which round was the parent) and modification (the verbatim natural-language description of the change the LLM proposed) so the next iter can build a parent → {success: [...], failure: [...]} lookup table for the optimize_prompt's "Absolutely prohibit X (Score: y)" experience block.
  4. No 7-seed palette. adas-subagent pre-seeds archive.jsonl with 7 ADAS reference patterns (text-only, fitness:null) so the meta-LLM has a starting design palette. aflow follows upstream — the only initial entry is iter_0 (baseline workflow scored on the perf_ tier + bootstrap_CI). The optimize_prompt has no slot for ungrounded patterns.
  5. Two-tier evaluation (smoke / perf). Same shape as adas-subagent — a smoke gate inside the implementer's debug-retry loop + a per-iter perf_<graph> eval whose acc_list the Atomic Writer turns into the archive's score. (The 2026-05-20 three-tier smoke / search / perf design was reverted on 2026-05-25 after the v0 mapgpt_mp3d run showed its small frozen search_ subset noise floor ≈ 9pp was larger than the ∼5–10pp gains the search could detect; iter_6 won the search by +13pp and lost perf by −4.2pp.) Every archive.jsonl score is now a single-pass perf_<graph> value; step 8 adds verification reruns on top-1, top-2, and baseline to calibrate residual LLM run-to-run stochasticity. See § “Two-tier evaluation” below.

Failed iters remain atomic per the shared files-contract: no iter dir, no archive append. The per-vN archive.jsonl, fitness_str in metrics.json, and the .staging/iter_n/ pre-commit dir all carry over from adas-subagent unchanged.

At-a-glance — cycle + working memory

Same shape as the myloop reference diagram: per-iter phases run top-to-bottom on the left, the persistent archive lives on the right, dashed lines show what each phase reads from or writes to it. The large flow diagram immediately below preserves the per-skill mechanics (anti-replay while True inside proposer, parent-checkout inside implementer, Atomic Writer); this view is the 10-second summary. The defining shape difference vs ADAS: the proposer picks a parent from the archive each attempt (any prior iter is eligible, weighted by score) and feeds that parent's experience block into a single LLM call — no 3-call Reflexion.

P0 understand + iter_0 baseline load context · run baseline (perf_, full set) → archive (iter_0 only — no 7-seed) for opt_round (max 20): proposer (anti-replay) while True: select_round (λ·U + softmax) one LLM call · check_modification → proposal.md {parent_iter_id, modification} implementer cp parent's active_workspace apply patch → smoke → debug retry ≤3 3-exhausted → SKIP evaluator perf eval (perf_<graph>, full set) validation_rounds=1 [F3] writes neutral metrics.json Atomic writer + convergence check bootstrap_CI → fitness_str + score; mv staging + archive.append + check_convergence next opt_round Working memory (persistent per vN) Central archive (parent-aware) • archive.jsonl — iter_0 baseline (parent_iter_id:null) — each iter: {generation, iter_id, parent_iter_id, name, thought, modification, fitness, score} ↑ the two new fields drive anti-replay Per-iter forensics • iter_M/active_workspace/ (parent-checkout-based) • iter_M/debug_log.md • iter_M/{metrics.json, summary.csv, export.json} • iter_M/proposal.md Roll-ups • trace.md (one row per iter) • lineage.md (narrative per iter) SKIP path: rm -rf .staging/iter_n/ — no iter dir, no archive append, no anti-replay update Convergence: top-3 stability check (advisory; mostly no-op) reads archive (top-K parents) + parent log.jsonl × 3 reads parent's active_workspace; writes staging writes metrics.json to staging promote staging → iter_M · append archive entry

Detailed per-iter flow

understand (P0 skill — runs once before loop starts) read-only: load files-contract + AgentCanvas concept/contract docs + AFlow Section 1 invariants + graph state loop skill (orchestrator — owns the conversation, drives the 3 worker skills per iter, runs convergence check) // iter_0 (pre-seed: baseline only — no 7-seed palette in aflow) iter_0 baseline loop → evaluator (perf_<graph>, full set) v{N}/archive.jsonl head [iter_0 entry: parent_iter_id=null, modification="(baseline)", fitness=…] for opt_round in range(max_rounds = 20): ONE Claude conversation per iter — proposer's anti-replay retry thread + implementer's debug retry thread share msg_list proposer skill while True (anti-replay): select_round (λ⋅U + softmax) load parent experience + log×3 build optimize_prompt → LLM single call check_modification → resample? out: proposal.md (staging) {parent_iter_id, modification, graph_patch, prompt_patch, thought} implementer skill cp parent's active_workspace → .staging/iter_n/active_workspace apply patch → workspace Smoke eval (5 ep, ~10 min) classify {crash, step=0, all-zero, ok} debug retry (LLM, ≤3) [adas-subagent add-on] out: smoke-pass workspace + debug_log.md (staging) evaluator skill perf eval — perf_<graph> (full paper-comparable set) validation_rounds=1 [F3: upstream 5] writes neutral metrics.json (method-free — no bootstrap here) out: staging metrics.json summary.csv, export.json (no retry on low value) 3 retries exhausted → SKIP all-duplicate after K → SKIP success loop: Atomic Writer bootstrap_CI → fitness_str + score; mv staging → iter_n/ + trace/lineage v{N}/archive.jsonl << entry {generation, iter_id, parent_iter_id, name, thought, modification, graph_summary, diff_narrative, fitness, score} SKIP path: rm -rf .staging/iter_n/ — no iter dir, no archive append loop terminates when: max_rounds (20) reached · STOP file · convergence (top-3 stability) · consecutive SKIPs ≥ K
proposer (orange fill = LLM caller; contains anti-replay loop) per-iter Claude conversation envelope persistent state (archive, atomic-write artifacts) SKIP path (no commit, no archive append)

Skill structure (5 skills)

aflow reuses adas-subagent's 5-skill skeleton 1-for-1 — understand / loop / proposer / implementer / evaluator. The split is the same, the conversation contract (one iter = one Claude conversation) is the same, the staging-then-atomic-commit is the same. What differs is what happens inside each skill, especially proposer (single-call + anti-replay) and loop (parent selection + convergence check).

SkillRole in aflowSVG regionReadsWrites
understand P0 bootstrap — loads files-contract + AgentCanvas concept/contract docs + Section 1 invariants + graph state. Same shape as adas-subagent:understand; forked only for namespace isolation and to point at aflow-specific contract notes. (off-diagram; runs once before the loop starts) files-contract, Section 1 invariants, {graph}.json, {graph}.yaml, trace.md, latest iter (read-only)
loop Orchestrator. Owns the conversation for an entire iter. Drives the pre-seed perf_<graph> eval on iter_0 (baseline only — no 7-seed palette), then for each iter_n: invokes proposer → implementer → evaluator, decides SKIP vs commit, calls Atomic Writer, appends to archive / trace / lineage. Runs the check_convergence top-3-stability check after each commit (advisory: under F3 every per-round σ is 0, so the predicate fires only on exact top-3-mean equality regardless of z — mostly a no-op, kept for upstream faithfulness). Step 8 runs three verification reruns of perf_<graph> (top-1, top-2, baseline) to calibrate in-loop single-pass scores against LLM run-to-run stochasticity. Terminates on max_rounds, STOP file, convergence, or consecutive-SKIP ≥ K. Outer for opt_round dashed frame; iter_0 row; atomic WRITE + archive.append row archive.jsonl, trace.md, prior iters' active_workspace/ (for parent checkout) iter dir (via Atomic Writer), archive.jsonl append, trace.md row, lineage.md section, .staging/iter_n/active_workspace/ (parent checkout step)
proposer Single-call + anti-replay. One LLM call per attempt via call_with_format(GraphOptimize); on FormatError, regex-extract fallback (verbatim optimizer.py:164–174); on duplicate modification, re-sample parent and re-call LLM in the same conversation. The anti-replay while True is bounded by a soft cap K (e.g. 5) to avoid infinite resampling on near-converged archives. Output: proposal.md = {parent_iter_id, modification, graph_patch, prompt_patch, thought, name}. Folds the old analyze step inside its prompt-assembly Python helper. Orange proposer box (the entire anti-replay while True loop) archive.jsonl (full read for top-K + experience aggregation), prior iters' log.jsonl (3 random failure samples from chosen parent), {graph}.yaml staging proposal.md (only committed by Atomic Writer on success)
implementer Reuses adas-subagent's implementer with one preamble: copy parent's active_workspace/ into .staging/iter_n/active_workspace/ before applying patch. Then: apply patch → Smoke (5 eps) → classify (crash / step=0 / mean<0.01 / ok) → on failure: revert to parent's checkpoint, push (err, run_summary) to msg_list, ask LLM for a new patch with debug_thought, retry. Max 3 retries (debug_max=3). On 3-exhausted → SKIP iter. Debug retry is an adas-subagent add-on, not in upstream AFlow — AgentCanvas substrate forces it (see Open Q1 below). Implementer box (white) inside the orange envelope proposal.md (from proposer's conversation state), parent's active_workspace/ edits .staging/iter_n/active_workspace/{graphs,nodesets}/; debug_log.md in staging; SKIP signal to loop on exhaustion
evaluator Method-free per-iter perf eval: runs the perf_<graph> profile (full paper-comparable set) via experiment:run → writes a neutral metrics.json (acc_list + primary_metric_value + secondary_metrics). It does not compute bootstrap_CI / fitness_str / score — that enrichment lives in loop's Atomic Writer. Invoked once per iter, after implementer succeeds; also invoked by loop's step-8 verification reruns (top-1, top-2, baseline). No retry on low value. Every archive.jsonl score is a single-pass perf_<graph> value (post 2026-05-25; see § “Two-tier evaluation”). Evaluator box (white) inside the orange envelope .staging/iter_n/active_workspace/ (frozen by implementer's success), exp.yaml staging metrics.json (neutral), summary.csv, export.json; fitness_str + score + iter dir + archive append committed by loop's Atomic Writer

Why no parent_selector as a separate skill? Upstream's select_round is a 30-line numpy helper, not an iter-level stage. It runs inside the proposer's while True body (re-sampled on each anti-replay attempt). Promoting it to a skill would force the anti-replay loop to cross skill boundaries, which would break the “one iter = one Claude conversation” contract. Decision: keep it as a Python helper inside proposer, mirror upstream's data_utils.select_round structurally.

Two-tier evaluation (smoke / perf)

Same shape as adas-subagent — a cheap Smoke gate inside the implementer's debug-retry loop + a per-iter Performance eval whose acc_list the Atomic Writer turns into the archive's score. Two {graph}.yaml blocks:

TierProfileWhenRole
Smoke smoke_<graph> (~5 ep) inside implementer's debug-retry loop Cheap gate: “does the patched graph even run?” Runtime correctness only.
Perf perf_<graph> (full paper-comparable set) every iter (the evaluator's profile_key); plus step-8 verification reruns on top-1, top-2, baseline The per-iter ranking eval AND the source of headline numbers. loop's Atomic Writer turns its acc_list into fitness_str + the bare score that select_round's softmax consumes. Every archive.jsonl score is a single-pass perf_ value. Verification reruns live in final_report.md, not archive.jsonl.

Why two tiers (revert from 2026-05-20 three-tier → 2026-05-25): the 2026-05-20 design inserted a search_<graph> middle tier — a small frozen stratified subset (~30 ep) — for per-iter ranking, with perf_ paid once post-loop. The aflow/mapgpt_mp3d/v0 run exposed why this didn't work: N=30 + validation_rounds=1 gives binomial SE ≈ 9pp at p=0.5, while genuine gains in this regime are ∼5–10pp. The loop ranked iters on a signal smaller than its noise floor. iter_6 won the search at +13pp over baseline; on perf it was −4.2pp. The whole archive was a noise gradient. Running perf_<graph> every iter at full N (e.g. 216 for mapgpt_mp3d) drops binomial SE to ≈ 3.4pp, restoring SNR > 1 against the gains aflow tries to detect. Cost goes up ∼5× $ per iter; wall is similar (perf was paid 2× post-loop in the old design, plus 20 search runs at ∼the same wall as perf runs at the right worker_count). See outputs/design_runs/aflow/mapgpt_mp3d/v0/final_report.md for the full v0 postmortem.

F3 deviation. Upstream averages validation_rounds=5 separate passes per round; aflow still runs 1 pass per iter. The two-tier revert fixes episode-sampling noise (small-N → full-N) but not LLM run-to-run stochasticity (temperature=1 nondeterminism across passes). The bootstrap CI in fitness_str resamples within the one pass — it captures the first kind, not the second. This propagates into the search policy: the single-pass score drives select_round's softmax and check_convergence's stability test, both of which assume score is comparable across iters. A lucky single pass can mis-rank a mediocre iter into the top-K; and because every per-round σ is 0 under validation_rounds=1, check_convergence's z·σ tolerance collapses to 0 — the convergence_z tunable is inert and convergence fires only on exact top-3-mean equality. Mitigations in this port:

  1. N=full perf removes the dominant noise component (episode sampling) — see two-tier rationale above.
  2. Step-8 verification reruns (top-1, top-2, baseline each get one additional pass) calibrate the residual LLM stochasticity and flag when an in-loop top iter was a lucky pass.
  3. Raise aflow.lambda_uniform if select_round collapses onto one parent.
  4. Future option (not enabled): run baseline 3× before launching a new run to set an empirical noise floor / minimum credible Δ threshold.

What's different from adas-subagent

Aspectadas-subagentaflow
Per-attempt LLM calls 3 (propose / Reflexion_1 / Reflexion_2) 1 (single call_with_format(GraphOptimize)) — verbatim optimizer.py:156–160
Conversation thread role Reflection chain (the LLM critiques + revises its own prior proposal) Anti-replay retry chain (the LLM gets a fresh parent + fresh optimize_prompt on each retry; no cross-attempt reflection)
Parent selection Implicit — always archive head p = λ⋅uniform + (1−λ)⋅softmax(α⋅score) over top-K of archive.jsonl; α=0.2, λ=0.3; iter_0 unconditionally in top-K
Anti-replay None — meta-LLM may propose duplicates; archive injection is expected to discourage them Per-parent: LLM is shown parent's previous {success, failure} modification strings as “Absolutely prohibit X (Score: y)”; if response duplicates anyway, regenerate (resample parent + re-call LLM)
FormatError handling 3-call Reflexion chain mostly avoids it; on JSON parse failure, SKIP the iter Regex-extract fallback for <modification> / <graph> / <prompt> tags from raw text; on extraction failure, continue the anti-replay loop — verbatim optimizer.py:164–174
Per-iter eval 1 pass × 100 eps (perf_<graph>) + bootstrap_CI, every iter 1 pass × full perf_<graph> every iter + bootstrap_CI [two-tier post 2026-05-25; F3 deviation remains]. Step 8 adds verification reruns on top-1, top-2, and baseline. Upstream averages validation_rounds=5 passes; aflow runs 1 + bootstrap resample-within-set
Initial archive Baseline (scored) + 7 reference pattern descriptors (text only, fitness:null) Baseline only — verbatim upstream (round_1 = template/ workflow); no design-palette pre-injection
Convergence detection None — runs to max_iters or STOP or SKIP-cap Top-3-mean stability over 5 consecutive rounds — verbatim convergence_utils.py:68–113. Under F3 (validation_rounds=1) per-round σ=0, so the z·σ tolerance is 0 and it fires only on exact equality regardless of z; advisory only
Workspace state model Live workspace/ tracks archive head; implementer edits in place Per-parent checkout: loop copies parent's iter_X/active_workspace/ into .staging/iter_n/active_workspace/ before implementer applies patch. Live workspace/ stays at the user's pre-loop state (advisory)
Archive entry shape {generation, iter_id, name, thought, graph_summary, diff_narrative, fitness} Adds parent_iter_id + modification + bare numeric score (alongside the human-readable fitness) so next iter can compute parent → {success, failure} lookup + softmax weights
Meta-LLM params Pinned: temperature=0.8, response_format=json_object, max_tokens=4096, model=gpt-4o-2024-05-13 Pinned upstream defaults from config2.yaml: temperature=0.7, response_format=XmlFormatter (against GraphOptimize pydantic schema), max_tokens per provider, model=claude-3-5-sonnet or gpt-4o per the run config

Core components (10 modules) and skill ownership

Each component lives inside one of the 5 skills. “Owner” = which skill's code path instantiates the component. Components in the same skill share that skill's conversation context. The numbering follows adas-subagent's table for cross-doc comparability (some adas-subagent components are merged or absent here).

#ComponentOwner skillRoleUpstream anchorReused from adas-subagent?
1Parent Selectorproposer get_top_rounds + select_round: top-K by score (round_1 unconditionally included), then softmax-mix sample; runs inside the anti-replay loop data_utils.py:40–109 New — adas-subagent has no parent selection
2Experience Loaderproposer load_experience + format_experience: aggregate per-parent {success, failure} from prior archive entries' modification field; format as “Absolutely prohibit X (Score: y)” experience_utils.py + optimizer.py:141–142 New
3Optimize Prompt Builderproposer create_graph_optimize_prompt: assemble {experience, parent's score, parent's graph, parent's prompt, operator descriptions, 3 random failure samples from parent's log.jsonl} into one WORKFLOW_INPUT template optimizer.py:149–151 + prompts/optimize_prompt.py Replaces adas-subagent's get_prompt(archive) + analyze helper (different prompt shape, no [ARCHIVE] dump)
4Single-Call LLM + Format Fallbackproposer One call_with_format(GraphOptimize); on FormatError, raw call + regex-extract <modification> / <graph> / <prompt>; on extraction failure, continue the anti-replay loop optimizer.py:153–174 New — adas-subagent has 3-call Reflexion + JSON schema, no regex fallback
5Modification Anti-Replayproposer check_modification: reject if response's modification string is in parent's success OR failure list (success means “already tried, don't repeat”); on reject, continue the inner loop with re-sampled parent optimizer.py:177–183 + experience_utils.py:69–80 New
6Workspace Checkoutloop cp -r outputs/.../iter_{parent}/active_workspace/ → .staging/iter_n/active_workspace/ after reading parent_iter_id from proposal.md frontmatter; runs once per iter, before invoking implementer (upstream is stateless — write_graph_files writes a fresh round_{N+1} dir from the LLM's full source dump) New — adas-subagent has no parent checkout (live workspace = archive head)
7Native-edit implementerimplementer Spawns an editing sub-agent that realizes the proposer's {intent, targets} change spec by editing .staging/iter_n/active_workspace/{graphs,nodesets}/ with native Edit/Write; JSON + Python validated after the edit upstream is wholesale rewrite via write_graph_files Shared _common/implementer.md; seeding via _common/lib/overlay.py (typed op DSL retired 2026-05-20)
8Smoke Evaluator + Debug Retryimplementer 5-ep smoke + outcome classification + ≤3 debug retries pushing (err, run_summary) to msg_list; on exhaustion SKIP (upstream has no debug retry — FormatError fallback is proposer-side only) Reused from adas-subagent — AgentCanvas-substrate add-on, not in upstream AFlow
9Perf Evaluator (method-free) + Bootstrap-CIevaluator / loop evaluator runs the perf_<graph> full-set eval and writes neutral metrics; loop's Atomic Writer does acc_list → bootstrap_CI → fitness_str + bare-numeric score (for parent-selection softmax). validation_rounds=1 per F3. Step 8 adds verification reruns of perf_<graph> on top-1, top-2, baseline evaluation_utils.evaluate_graph with validation_rounds=5 Reused from adas-subagent — reps reduced 5→1 (F3); per-iter tier is perf_ (two-tier post 2026-05-25; pre-revert this was search_)
10Atomic Writer + Convergence Checkloop mv .staging/iter_n/ → iter_n/ + archive.jsonl append + trace.md row + lineage.md section, in one transaction. Then check_convergence(top_k=3, z=0, consecutive_rounds=5) decides whether to early-exit (advisory, rarely fires) convergence_utils.py:68–113 + optimizer.py:108–116 Atomic Writer reused from adas-subagent; convergence check new

Archive contract

Same role as adas-subagent's archive.jsonl (per-vN, JSONL, append-only, atomic write paired with iter-dir mv) but with two extra fields that the parent-selection algorithm reads on every iter: parent_iter_id (so the experience loader can group entries by parent) and modification (so check_modification can detect duplicates). A bare numeric score is also stored alongside the human-readable fitness string, because select_round's softmax needs a float, not the “95% Bootstrap CI: (lo%, hi%), Median: x%” rendering.

AspectDecisionRationale
Pathoutputs/design_runs/{graph}/v{N}/archive.jsonlPer-vN. Major pivots (new vN) start a fresh archive; meta-LLM is not anchored to a dead-end design family.
FormatJSON Lines, append-only.Crash-safe; each line independently parseable; identical to adas-subagent.
Writerloop (Atomic Writer), in the same transaction as mv .staging/iter_n/ → iter_n/SKIP path does neither — archive only sees successful generations.
Reader (parent selection)proposer reads the full file each attempt; runs get_top_rounds(K) + select_round(softmax-mix).Cost is trivial up to ~hundreds of entries; deferred sliding-window pruning to when archive grows beyond that.
Reader (experience aggregation)proposer aggregates entries by parent_iter_id into a {parent: {success: [modifications], failure: [modifications]}} dict; injects formatted “Absolutely prohibit” strings into the optimize_prompt.Mirrors upstream load_experience + format_experience; the parent_iter_id field is what makes per-parent grouping possible at all.
Failure handlingFailed iters (proposer SKIP / implementer SKIP / evaluator infra fail) do not append.Verbatim upstream semantics (the self.round += 1 orphan-round case from optimizer.py:96, 105).
Pre-append cleanupStrip debug_thought field before append (kept in debug_log.md for forensics).adas-subagent strips reflection + debug_thought; aflow only has the latter (no Reflexion chain).
Resumewc -l archive.jsonl → current generation; full-file scan for parent selection.Filesystem is the source of truth, no separate state file.

Per-entry schema (evolved entry)

{
  "generation": 3,                                       // int (1+ for evolved) | "initial" (baseline)
  "iter_id": "iter_3",                                   // soft pointer to evidence; stripped from optimize_prompt injection
  "parent_iter_id": "iter_1",                            // chosen parent for this iter (NEW vs adas-subagent)
  "name": "Self-Audit Chain-of-Thought via History Tracker",
  "thought": "**Insights:** ... **Overall Idea:** ... **Implementation:** ...",   // verbatim from LLM output
  "modification": "Add a HistoryTrackerNode upstream of NavGPTReason; wire history.summary into navgpt.context; bump max_steps from 5 to 15.",   // NEW vs adas-subagent — load-bearing for anti-replay
  "graph_summary": {
    "nodes": [
      {"id": "env_mp3d__step",     "type": "env_mp3d.step",       "key_config": {"action_space": "discrete_4"}},
      {"id": "navgpt__think",      "type": "navgpt.think",        "key_config": {"llm_model": "gpt-4", "system_prompt_first_200": "You are a navigation agent..."}},
      {"id": "history__update",    "type": "history.update",      "key_config": {"max_steps": 15}}
    ],
    "wires": [
      ["env_mp3d__step.observation",  "history__update.obs"],
      ["history__update.summary",     "navgpt__think.context"]
    ],
    "loop": {"iter_in": ["controller__step_gate"], "iter_out": ["navgpt__think"]}
  },
  "diff_narrative": "Added history__update node before navgpt__think; wired its summary output into navgpt__think.context; bumped max_steps from 5 to 15.",
  "fitness": "95% Bootstrap CI: (66.8%, 76.2%), Median: 71.5%",  // human-readable, for archive viewing
  "score": 0.715                                          // bootstrap median, fraction in [0,1], for select_round softmax (NEW vs adas-subagent)
}

Per-entry schema (initial baseline)

{
  "generation": "initial",
  "iter_id": "iter_0",
  "parent_iter_id": null,
  "name": "",
  "thought": "Baseline graph as provided by the user — design starting point.",
  "modification": "(baseline)",                          // sentinel; never matched by check_modification
  "graph_summary": ,
  "diff_narrative": "Baseline (no parent).",
  "fitness": "95% Bootstrap CI: (lo%, hi%), Median: x%",
  "score": 
}

Why parent_iter_id instead of an ancestor chain?

Upstream's experience.json only stores father_node — the immediate parent. There is no grandparent traversal, no visit-count backprop, no sibling statistic. AFlow's “tree” lives only as a flat list of (parent, child) edges written into experience.json files; the optimizer reads them back as a {parent: {success: [...], failure: [...]}} dict and never reconstructs the tree topology (experience_utils.py:12–53). aflow preserves this — one field parent_iter_id in each archive entry is sufficient to rebuild the experience map; no recursion needed.

This is also why the Section 1 callout warns against the “MCTS” framing: a real MCTS implementation would maintain per-node visit counts and backprop reward up the ancestor chain on each evaluation. AFlow has neither — the anti-replay surface and softmax-mix selection are the only mechanisms by which exploration history feeds back into selection. The flat parent_iter_id field is faithful to that.

Open questions for discussion