Skip to content

feat(kernelforge): collect KernelForge runs into the Experience KB - #2037

Open
amd-ama10002-2 wants to merge 16 commits into
mainfrom
feat/kernelforge-experience-kb
Open

amd-ama10002-2 wants to merge 16 commits into
mainfrom
feat/kernelforge-experience-kb

Conversation

@amd-ama10002-2

@amd-ama10002-2 amd-ama10002-2 commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

This PR adds Experience KB support for KernelForge runs. Each plain forge-loop run becomes one Experience per candidate kernel it measured, carrying the run's metadata instead of its raw logs. It also changes KernelForge's logs so they record what the KB extracts.

Supersedes #1949 and #1944.

What changes

Log format

  • Planner plans open with a Summary: line: one to three sentences stating the problem and the goal.
  • A lane records its lesson at the end of its round, while its copy of the workspace still exists. lane_queue.json carries that record, or the reason there is none, to the iteration that measures the lane, which writes it as lessons/iter_NNN.md. The queue stores the reason in lesson_reason. Later Implementer prompts read that file.
  • forge-loop writes forge_experiments/.gitignore (*) when it starts, as the Hyperloom controller already did. It hides the loop's output, and nothing written there is rejected by the guard.
  • Refactors with nothing observable: the planner prompts move to plan_prompts.py, the in-session gate's scoring inputs reach agent_fn as one GateScoring, the loop shares the controller's output-folder helper, the exporter and the writers share the archive layout and the log formats, KernelForge's decision labels are the one DecisionLabel set, and a lane is handed session_sink directly. A lane agent that does not accept the sink now fails loudly; the only production factory accepts it.

Task preparation fix

  • The rewrite controller stages an operator's driver in a workspace folder that hides itself from git. When the preparation agent repaired such a driver, nothing could be committed, so preparation rolled the repair back and failed with "prepared a conforming driver but the git commit did not land". It now fails only when a staged change does not land; with nothing staged, the repaired driver runs in place.

Collecting runs

  • New kernelforge export-runs writes one entry per candidate a run archived, so each lane of a fan-out round is its own entry. A run or candidate whose JSON records are missing or malformed becomes an entry saying why.
  • It reads the run's forge-loop.log only for a kept candidate's KB record, but no entry links the log.
  • For each candidate it also reads the report.md of the published Analysis generation its planning round named as evidence in orchestration/iter_NNN/context.json, found as analysis/<commit>/generation-NNN under the run's own forge_experiments. kernelforge.run_export takes the text-field limit from hyperloom_kb.schema.TEXT_MAX_BYTES, a new dependency of KernelForge on hyperloom_kb.

Observable effects

  • hyperloom-kb-collect --mapping kernelforge-v1 now collects KernelForge runs into the Experience KB. Before, a later run had no way to read what an earlier run tried on the same kernel.
  • Every Experience also holds reflection.analysis_report, the whole report.md of the Analysis generation its plan was built on: the baseline's report until Analysis refreshes after a KEEP, and on campaigns over two hours a report built from a rocprof-compute profile. provenance.extra.analysis_generation names the generation. A report over the 32 KB text limit is left out, and provenance.extra.analysis_report_omitted says so. No raw profiler output (pmc_perf.csv, rocprofv3 counter CSVs, profile/, .rocprofv3/) is declared, linked or stored. Without it, an Experience held none of the profiling evidence the planner read.
  • Lane iterations now write lessons/iter_NNN.md, which later Implementer prompts read.
  • Each lane spends one short read-only resumed turn at the end of its round.
  • lane_queue.json carries each lane's lesson, or lesson_reason when there is no record.
  • An Experience no longer stores the whole meta.json, so a token-named case id or a per-token plan is collected instead of skipped.

Usage

kernelforge export-runs <LOG_ROOT>... -o runs.json
hyperloom-kb-collect --mapping kernelforge-v1 --document runs.json

Add --dry-run to the second command to see the Experiences without writing them.

Latest declaration: src/hyperloom_kb/declarations/kernelforge-run-v1.yaml
# KernelForge forge-loop runs: one Experience per candidate kernel a run archived (candidates/iter_NNN), the kernel
# one iteration measured and KernelForge's decision to keep or revert it. A run yields one or more: each lane of a
# fan-out round is measured as its own candidate, built from its own plan, so each lane is its own Experience.
# rationale.reasoning is the whole lesson KernelForge writes for the iteration, lessons/iter_NNN.md: the implementer's
# record of what it explored, including HELD-FIXED, NEGATIVES, and DISPROOF, with the loop's scope and outcome lines
# after the verdict. When that file is absent, a single session's reasoning is the first three sentences of the
# rationale its meta.json records. A lane or a merge with neither has none. A lesson longer than the 32 KB a text
# field holds makes collect skip the candidate. Reasoning is required: a candidate whose logs hold none, such as a
# merge of two earlier candidates, which no agent made, becomes no Experience.
# provenance.extra holds the candidate's metadata, which never reaches a prompt: meta, the scalar measurement and
# decision fields of the candidate's meta.json (commit_hash, decision, kept, validation_passed, wall_ms,
# mean_case_speedup, snr_db, vgpr, baseline_wall_ms, best_wall_ms_before, best_mean_case_speedup_before,
# delta_vs_best_pct, session_end_reason, turns, agent_profile_id, agent_provider, agent_model, blockage_epoch), not
# its plan, rationale, bench, profile, pmc_diagnosis, or shape; kb_record, the record KernelForge's own experience KB
# filed a kept candidate under; candidate_origin, session or lane; analysis_generation, the Analysis generation
# reflection.analysis_report comes from, as analysis/<commit>/generation-NNN under forge_experiments; and
# analysis_report_omitted, why a report too long for a text field is not held.
# Every Experience has a raw_logs field, and it is intentionally empty for now: it links one 0-byte file,
# raw-logs-not-collected.log, which kernelforge export-runs writes beside its document. The global KB refuses a
# complete Experience without raw logs, and KernelForge's run logs are not collected yet. raw_logs is a top-level
# Experience field, not a field of a category, so it is not declared here and takes no part in the schema_ref. Beyond
# it an Experience carries none of the profiler's output: the only run files it links are the plan, the measured
# kernel, and the archived diff of every file the commit touched (candidates/iter_NNN/change.diff). The diff and the
# kernel file are different records: the diff has every file the commit touched and not the kernel's unchanged lines,
# and the kernel file has none of the other files. The only Analysis output an Experience holds is the report its plan
# was built on, as text. The lesson is rationale.reasoning, not a second copy under reflection.
# The objective and every identity field always form the repeat group, with the schema: Hyperloom groups Experiences
# that way by itself (hyperloom_kb/query_view.py repeat_group_key) and accepts group: true only on baseline and change
# fields, so none is declared here.
schema_version: 2

objectives:
  - id: kernel_time_ms@v1
    description: >-
      Minimize the wall time the kernel takes on the task's benchmark, in milliseconds: the time KernelForge
      records for it (wall_ms). That time is the median of three run aggregates. KernelForge runs the task's
      benchmark driver three independent times. One run's time is the median of the wall_ms samples that run
      printed. If the driver prints one median_ms or mean_ms line instead, that printed number is the run's time.
      Grouped GEMM's mean_ms is the arithmetic mean of that run's per-case times, so each of its runs contributes
      a mean, and the recorded time is the median of those three means. KernelForge does not decide on this time:
      it keeps a candidate only when the candidate's mean case speedup, the equal-weight mean over the benchmark
      cases of the original kernel's time on a case divided by the candidate's, clears a bar KernelForge sets from
      the best result so far and the spread of those runs. A KEEP can still be slower on this time, because
      KernelForge keeps on mean case speedup rather than on it.
    unit: ms
    direction: lower_is_better

identity:
  - {name: operator, description: "Operator the kernel implements: the operator_name the run's campaign_config.json records, e.g. _softmax_kernel, gemm_a8w8_blockscale_bpreshuffle."}
  - {name: kernel, description: "Kernel the run optimizes: the logical operator KernelForge files its KB records under, the operator's name normalized to lower snake case without a trailing _kernel, e.g. softmax, gemm_a16w16_nt_n1024_k4096."}
  - {name: framework, description: "Framework that owns the source operator, e.g. aiter. Absent when the run records it as unknown."}
  - {name: backend, description: "Kernel language of the candidate, e.g. triton, flydsl, hip."}
  - {name: backend_version, description: "Release of the candidate's kernel language in the run's runtime, e.g. 0.3.2. forge-loop does not record it yet, so Experiences from its runs leave it absent.", kind: version}
  - {name: gpu, description: "GPU model the candidate was measured on, which also fixes its architecture target, e.g. mi355x (gfx950), mi300x (gfx942)."}
  - {name: dtype, description: "Data type of the operator's first input in the benchmark cases, e.g. bfloat16, float8_e4m3fn. forge-loop does not record it yet, so Experiences from its runs leave it absent."}
  - name: driver_sha256
    description: >-
      SHA-256 of the benchmark driver file's bytes, as the run's campaign_config.json records it after preparation.
      The hash is here so two runs of one operator on different benchmark drivers do not share a repeat group and
      have their times pooled. The same driver bytes group together. A preparation rewrite that changes the driver
      bytes splits them, because that rewrite is not deterministic; an unchanged driver does not get a new hash.
      Absent when the run recorded none.

baseline:
  - name: best_wall_ms_before
    description: >-
      The recorded wall time of the best kernel KernelForge had kept when the iteration began, in milliseconds, as
      the candidate's meta.json records it (best_wall_ms_before): the same median of three run aggregates as
      candidate_wall_ms, for the kernel the candidate was measured against. Until the first KEEP, it is
      KernelForge's own measurement of the kernel the forge-loop started from. KernelForge replaces it only when it
      keeps a candidate, with that candidate's time; since it decides the KEEP on mean case speedup rather than on
      this time, the value can occasionally rise.
    kind: number
    role: measurement
  - name: source_ms
    description: >-
      How long the original kernel took before any change, in milliseconds, the same median of three run aggregates:
      the pristine baseline the run's run_state.json records, which for a run given --baseline-json is that file's time rather
      than the time of the kernel the search started from. A run state that records none leaves the baseline time
      the candidate's meta.json archives (baseline_wall_ms).
    kind: number

# Hyperloom adds reasoning, alternatives, and preconditions to every declaration. Writing them here fails with
# `declaration.rationale redeclares a default field`. For this declaration rationale.reasoning is the whole lesson.
rationale:
  - name: plan
    description: >-
      What the plan the candidate was built from sets out to do: the whole Summary line the plan opens with, the
      planner's statement of the problem it addresses and of its goal. For a lane, the plan is the lane's own; for a
      single session, the round's optimization plan, read from its original draft when KernelForge could not revise
      the draft and put the critic's review in front of it (kernelforge/orchestrator/orchestration.py:1711 at
      1c14e79e1); for a merge, the stacked plans as KernelForge records them. A plan without a Summary line, as every
      plan written before KernelForge asked for one is, gives its first three sentences instead: lines a planner wrote
      before the plan's first heading are skipped, as are headings, code blocks, and tables.
    kind: text
  - name: plan_file
    description: >-
      The whole plan the candidate was built from, as KernelForge archived it in the planning round's
      orchestration/iter_NNN/: optimization_plan.md for a single session or a round's first lane, lane_NNN.md for
      the round's other lanes. The Experience links to the file by its name, size, and SHA-256.
    kind: file

change:
  - name: source_code
    description: >-
      The kernel file that was measured, as KernelForge archived it in candidates/iter_NNN/. The Experience links to
      the file by its name, size, and SHA-256, and the knowledge base stores the file once. This is the anchor kernel
      only. A wrapper or config edit is in change_diff_file, and the unchanged lines of this file are not in that diff.
    kind: file
  - name: change_diff_file
    description: >-
      The unified diff of every file the commit touched, as KernelForge archived it beside the kernel in
      candidates/iter_NNN/change.diff. The Experience links to the file by its name, size, and SHA-256. The name says
      it is the diff file, not another copy of the kernel: the diff does not contain the kernel's unchanged lines,
      and the kernel file does not contain the other files the commit touched. Optional: absent when the candidate
      archived no diff.
    kind: file
    required: false

outcome:
  - name: decision
    description: KernelForge's decision label for an iteration, as its forge-loop runner assigns them.
    role: decision
    values:
      - KEEP
      - REVERT_PERF
      - REVERT_VALIDATION
      - REVERT_VALIDATION_TIMEOUT
      - REVERT_VALIDATION_ERROR
      - BUILD_FAILED
      - CRASH
      - REVERT_INTEGRITY
      - REVERT_CONTENDED
      - NO_CHANGES
      - API_ERROR
      - AGENT_ERROR
      - ORCHESTRATION_ERROR
  - name: candidate_wall_ms
    description: >-
      The candidate's recorded wall time in milliseconds, as its meta.json records it (wall_ms): the median of three
      run aggregates. KernelForge runs the task's benchmark driver three independent times. One run's time is the
      median of the wall_ms samples that run printed, or the single median_ms or mean_ms summary it prints, kept as
      printed. When the driver prints mean_ms, as the grouped-GEMM driver does for the arithmetic mean of its
      per-case timings, this is the median of those three means. It is reported, not decided on: KernelForge kept or
      reverted the candidate on its mean case speedup (provenance.extra.meta.mean_case_speedup), so a kept candidate
      can be slower than best_wall_ms_before.
    kind: number
    role: measurement

reflection:
  - name: analysis_report
    description: >-
      The whole report.md of the Analysis generation the candidate's plan was built on: KernelForge's cross-case
      summary of one kernel, which its Analysis agent publishes for the commit it analyzed as one immutable
      generation, forge_experiments/analysis/<commit>/generation-NNN/. Analysis runs on the baseline, and again after
      a KEEP once the canonical mean case speedup has improved 5% on the analyzed kernel's, or just before a
      Supervisor intervention; on a campaign longer than two hours it profiles the kernel with rocprof-compute,
      through rocpc_profile.py, and a shorter campaign's report is written from the source alone. The generation is
      the one the planning round's orchestration/iter_NNN/context.json names as its analysis_bundle evidence: until a
      refresh is published, rounds plan on the last report that was, so candidates on both sides of a KEEP can carry
      the same report. Optional: absent when the round named no published generation, as for a merge, which no round
      planned, and when report.md is over the 32 KB a text field holds, which provenance.extra.analysis_report_omitted
      then says. The profiler's raw output, such as pmc_perf.csv, the rocprofv3 counter CSVs, and the profile/ and
      .rocprofv3/ directories, is never stored: it is large and tied to one binary.
    kind: text
    required: false
Provenance fields an Experience keeps

The declaration's provenance comment:

# provenance.extra holds the candidate's metadata, which never reaches a prompt: meta, the scalar measurement and
# decision fields of the candidate's meta.json (commit_hash, decision, kept, validation_passed, wall_ms,
# mean_case_speedup, snr_db, vgpr, baseline_wall_ms, best_wall_ms_before, best_mean_case_speedup_before,
# delta_vs_best_pct, session_end_reason, turns, agent_profile_id, agent_provider, agent_model, blockage_epoch), not
# its plan, rationale, bench, profile, pmc_diagnosis, or shape; kb_record, the record KernelForge's own experience KB
# filed a kept candidate under; candidate_origin, session or lane; analysis_generation, the Analysis generation
# reflection.analysis_report comes from, as analysis/<commit>/generation-NNN under forge_experiments; and
# analysis_report_omitted, why a report too long for a text field is not held.

The mapping's provenance.extra projection (src/hyperloom_kb/mappings/kernelforge-v1.yaml):

    extra:
      compact:
        object:
          candidate_origin: $run.provenance.candidate_origin
          kb_record: $run.provenance.kb_record
          analysis_generation: $run.provenance.analysis_generation
          analysis_report_omitted: $run.provenance.analysis_report_omitted
          meta:
            compact:
              object:
                commit_hash: $run.provenance.meta.commit_hash
                decision: $run.provenance.meta.decision
                kept: $run.provenance.meta.kept
                validation_passed: $run.provenance.meta.validation_passed
                wall_ms: $run.provenance.meta.wall_ms
                mean_case_speedup: $run.provenance.meta.mean_case_speedup
                snr_db: $run.provenance.meta.snr_db
                vgpr: $run.provenance.meta.vgpr
                baseline_wall_ms: $run.provenance.meta.baseline_wall_ms
                best_wall_ms_before: $run.provenance.meta.best_wall_ms_before
                best_mean_case_speedup_before: $run.provenance.meta.best_mean_case_speedup_before
                delta_vs_best_pct: $run.provenance.meta.delta_vs_best_pct
                session_end_reason: $run.provenance.meta.session_end_reason
                turns: $run.provenance.meta.turns
                agent_profile_id: $run.provenance.meta.agent_profile_id
                agent_provider: $run.provenance.meta.agent_provider
                agent_model: $run.provenance.meta.agent_model
                blockage_epoch: $run.provenance.meta.blockage_epoch
Example Experience collected from a token-named case and a per-token plan

Collected with collect(..., dry_run=True) from kernelforge export-runs on the export fixture. No campaign log. The candidate's meta.json has bench.case_times.tokens_128_e8 and plan Summary: per token: one scale., and the plan file is that line. Collection stores the Summary line with the label removed, so rationale.plan is per token: one scale., and copies only the scalar fields onto provenance.extra.meta (mean_case_speedup is there; plan, rationale, and bench are not).

{
  "kind": "experience",
  "id": "exp-8afd082519ed5e1a90811bb841d5ddef",
  "schema_ref": "schema:sha256:090fe777e98b33e0487d53307df1bf72bafed2954a60826c3f7b84eb34387481",
  "schema_version": 2,
  "status": "complete",
  "created_at": "2026-10-15T10:00:00Z",
  "completed_at": "2026-10-15T10:04:30Z",
  "run_id": "5f1c0a9e2b7d4c38a6e0f1b2c3d4e5f6",
  "seq": 1,
  "parent_id": "",
  "supersedes": "",
  "objective": "kernel_time_ms@v1",
  "rendered_refs": [],
  "notes": {},
  "provenance": {
    "producer": "kernelforge",
    "producer_version": "10",
    "model": "",
    "prompt_version": "",
    "snapshot_version": "",
    "source_ref": "forge_loop_triton_softmax_1760500000/iteration-1",
    "extra": {
      "candidate_origin": "session",
      "meta": {
        "commit_hash": "abc1234",
        "decision": "KEEP",
        "kept": true,
        "validation_passed": true,
        "wall_ms": 0.044,
        "mean_case_speedup": 1.18,
        "baseline_wall_ms": 0.052,
        "best_wall_ms_before": 0.052,
        "best_mean_case_speedup_before": 1.0
      }
    }
  },
  "raw_logs": [
    {
      "name": "raw-logs-not-collected.log",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "bytes": 0
    }
  ],
  "identity": {
    "operator": "_softmax_kernel",
    "kernel": "softmax",
    "backend": "triton",
    "gpu": "mi300x"
  },
  "baseline": {
    "best_wall_ms_before": 0.052,
    "source_ms": 0.052
  },
  "rationale": {
    "reasoning": "One sweep saved a global read. Small rows gained most. More.\nHELD-FIXED: the output layout.\nNEGATIVES: a second pass over the row.",
    "plan": "per token: one scale.",
    "plan_file": {
      "name": "optimization_plan.md",
      "sha256": "5431453a14e09145b42559ab9ba655991e585adeeafb73f5b0d8033b75c81dee",
      "bytes": 31
    }
  },
  "change": {
    "source_code": {
      "name": "softmax_kernel.py",
      "sha256": "8c11f77dd3ad9c1b34f2358d7f659b17a7b8c49cf7f9783987243b8ba41aefc9",
      "bytes": 14
    },
    "change_diff_file": {
      "name": "change.diff",
      "sha256": "b0cf3bd3aaf4a8e3a8c5542d888534f1df3b735abc8808698391a3c111d57a10",
      "bytes": 48
    }
  },
  "outcome": {
    "decision": "KEEP",
    "candidate_wall_ms": 0.044
  },
  "reflection": {}
}
Example Experience, as the global KB stores it

The smallest of the five records from GET /v1/export for this schema (exp-294e0ff607015d5f9c555a2adcd54486).

{
  "baseline": {
    "best_wall_ms_before": 0.0193,
    "source_ms": 0.0223034984446
  },
  "change": {
    "change_diff_file": {
      "bytes": 1804,
      "name": "change.diff",
      "sha256": "2878c3c6a38fd3d496cb16859da174218b2ebcf2256576dc9e982a7a9bbcaf7c"
    },
    "source_code": {
      "bytes": 58301,
      "name": "kernel.py",
      "sha256": "c6bd04c1f9e5fed38b3faf602f55903240ba576320b420f069aab6d868bf8963"
    }
  },
  "completed_at": "2026-10-09T22:42:54Z",
  "created_at": "2026-10-09T22:04:11Z",
  "id": "exp-294e0ff607015d5f9c555a2adcd54486",
  "identity": {
    "backend": "flydsl",
    "driver_sha256": "5c5895c5c00265e98db657d8fd5e30060c4c65574f4bfec74c9b4cf1d1fed7a6",
    "gpu": "mi355x",
    "kernel": "gemm",
    "operator": "gemm_kernel"
  },
  "kind": "experience",
  "notes": {},
  "objective": "kernel_time_ms@v1",
  "outcome": {
    "candidate_wall_ms": 0.0198,
    "decision": "REVERT_PERF"
  },
  "parent_id": "",
  "provenance": {
    "extra": {
      "analysis_generation": "analysis/0ea69bb54ed1ff0e011e7092e0dca2d95c3ea187/generation-002",
      "candidate_origin": "session",
      "meta": {
        "baseline_wall_ms": 0.0469,
        "bench": {
          "case_bandwidth": {},
          "case_coverage_complete": true,
          "case_times": {
            "m_1": 0.0142897400539,
            "m_1024": 0.0247110600956,
            "m_128": 0.0157485499885,
            "m_16": 0.0146077300701,
            "m_2": 0.014520159997,
            "m_2048": 0.0311434799992,
            "m_256": 0.017194580026,
            "m_32": 0.0153865499888,
            "m_4": 0.0147289700247,
            "m_4096": 0.0420040100068,
            "m_512": 0.0208897599019,
            "m_64": 0.0148609500192,
            "m_8": 0.0147709200066
          },
          "mean_case_speedup": 1.1034889403370334,
          "measurement_count": 3,
          "measurement_mean_case_speedups": [
            1.1222475617175465,
            1.104227335364361,
            1.0839919239291924
          ],
          "measurements": [
            {
              "case_bandwidth": {},
              "case_times": {
                "m_1": 0.0142161299847,
                "m_1024": 0.0247810599394,
                "m_128": 0.0156849799398,
                "m_16": 0.0144397600647,
                "m_2": 0.014520159997,
                "m_2048": 0.0312618997693,
                "m_256": 0.017194580026,
                "m_32": 0.014687730046,
                "m_4": 0.0145153300837,
                "m_4096": 0.0419808397815,
                "m_512": 0.0206869698875,
                "m_64": 0.0145141400024,
                "m_8": 0.0146613600664
              },
              "median_ms": 0.0195,
              "message": "BENCH: mean=0.0195 ms",
              "stat": "mean",
              "success": true,
              "unscored_cases": []
            },
            {
              "case_bandwidth": {},
              "case_times": {
                "m_1": 0.0142897400539,
                "m_1024": 0.0247081999108,
                "m_128": 0.0159305499773,
                "m_16": 0.0146077300701,
                "m_2": 0.0141993300617,
                "m_2048": 0.0310894998349,
                "m_256": 0.0171237401105,
                "m_32": 0.0173997800238,
                "m_4": 0.0147289700247,
                "m_4096": 0.042155209966,
                "m_512": 0.0209653898701,
                "m_64": 0.0148609500192,
                "m_8": 0.0147709200066
              },
              "median_ms": 0.0198,
              "message": "BENCH: mean=0.0198 ms",
              "stat": "mean",
              "success": true,
              "unscored_cases": []
            },
            {
              "case_bandwidth": {},
              "case_times": {
                "m_1": 0.014864500016,
                "m_1024": 0.0247110600956,
                "m_128": 0.0157485499885,
                "m_16": 0.0158389699645,
                "m_2": 0.0154941399116,
                "m_2048": 0.0311434799992,
                "m_256": 0.0172557501309,
                "m_32": 0.0153865499888,
                "m_4": 0.0156249500159,
                "m_4096": 0.0420040100068,
                "m_512": 0.0208897599019,
                "m_64": 0.0156089400128,
                "m_8": 0.0157285399642
              },
              "median_ms": 0.02,
              "message": "BENCH: mean=0.0200 ms",
              "stat": "mean",
              "success": true,
              "unscored_cases": []
            }
          ],
          "median_ms": 0.0198,
          "message": "BENCH: 3 independent measurements, per-case median",
          "success": true,
          "unscored_cases": []
        },
        "best_mean_case_speedup_before": 1.1293627466034297,
        "best_wall_ms_before": 0.0193,
        "commit_hash": "",
        "complete": true,
        "decision": "REVERT_PERF",
        "delta_vs_best_pct": -2.291,
        "files": {
          "diff": "change.diff",
          "kernel": "kernel.py",
          "profile": null,
          "validation": "validation.txt"
        },
        "iteration": 13,
        "kept": false,
        "kernel_file": "kernel.py",
        "mean_case_speedup": 1.1034889403370334,
        "plan": "use BK 128 with the PIPE 3 spread-load schedule for M=1024",
        "pmc_diagnosis": "",
        "profile": {},
        "rationale": "[gate edits=9 pass=False end=candidate_submitted turns=48 mean_case_speedup=1.121535x raw_mean=0.0195ms] I applied iteration 12's m_1024 row (BK 128, PIPE 3) as the base, as the plan asks. Next I'm bu",
        "session_end_reason": "candidate_submitted",
        "shape": {},
        "snr_db": null,
        "timestamp": "2026-10-09 22:44:19",
        "turns": 48,
        "validation_passed": true,
        "vgpr": null,
        "wall_ms": 0.0198
      }
    },
    "model": "",
    "producer": "kernelforge",
    "producer_version": "10",
    "prompt_version": "",
    "snapshot_version": "",
    "source_ref": "engine/iteration-13"
  },
  "rationale": {
    "plan": "The large-M cases (m_2048 and m_4096, possibly m_1024) are limited by how many LDS fragment reads (`ds_read` bytes) they need per FLOP and by how few waves are available to hide latency. A wave's `ds_read` bytes depend only on its wave tile, so a bigger wave tile is the lever. Two ways to get one, which together make up the joint step:",
    "plan_file": {
      "bytes": 19066,
      "name": "optimization_plan.md",
      "sha256": "106884b5b3be172d7c5fae43b4fa793902b5df0df58b51d76d4cd809462cff5a"
    },
    "reasoning": "(no agent summary) session hit 10 gate rejection(s): The kernel is CORRECT but NOT faster than the current best, so it is not good en\n\nNo summarizer session was available for this iteration, so this record is machine-written from what the loop observed. It has no account of directions the agent tried and abandoned.\nSummary unavailable: WorkspaceSafetyError: the read-only session changed the workspace; restored the pre-run Git-visible state\nImplementer session ended: candidate_submitted\nImplementer turns: 48\nFinal plan: use BK 128 with the PIPE 3 spread-load schedule for M=1024\n\nNet change:\n  files: .forge_rewrite/20261009-152555-4dba86a3/kernel.py | changed\n\nIn-session gate rejections (each one the agent hit and retried):\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n\nRecent provider progress (machine-captured):\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.106209x; requir\n- say: The gate rejected the unchanged file a seventh time: it scored 1.106 against a required 1.166. The session is past its deadline, so I'm not making another chang\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.127093x; requir\n- say: The gate rejected the unchanged file an eighth time: it scored 1.127 against a required 1.140. The session is past its deadline, so I'm ending without another c\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.123532x; requir\n- say: The gate rejected the unchanged file a ninth time: it scored 1.124 against a required 1.134. The session is past its deadline, so I'm handing the file off as it\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.121535x; requir\n- end: subtype=success\n\nSCOPE: measured on m_1, m_1024, m_128, m_16, m_2, m_2048, m_256, m_32, m_4, m_4096, m_512, m_64, m_8 | held fixed (not recorded) | carries a measured negative | whether a feasibility claim was disproved was not recorded\n\nOUTCOME: REVERT_PERF | mean case speedup 1.103489x vs best 1.129363x | wall 0.0198 ms vs best 0.0193 ms | session ended: candidate_submitted"
  },
  "raw_logs": [
    {
      "bytes": 0,
      "name": "raw-logs-not-collected.log",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    }
  ],
  "reflection": {
    "analysis_report": "# Analysis report \u2014 `gemm_a16w16_nt_n1024_k4096`, canonical 0ea69bb5 (incremental from 3f155afd)\n\n**Mode:** PROFILED, incremental. **GPU:** MI355X (gfx950). PMC and traces ran on `ROCR_VISIBLE_DEVICES=3`; graph A/B, sweeps and ISA ran on device 4.\n\n**Measured in this session (0ea69bb5):**\n- rocprofv3 kernel traces for **all 13 cases**.\n- Own 9-group PMC (63 passes, all exit 0) on the **7 representatives**: m_1, m_8, m_16, m_64, m_512, m_2048, m_4096.\n- ISA plus a correctness check for the 7 changed binaries.\n- Hot CUDA-graph A/B of every M against the parent 3f155afd source.\n- An empty-kernel graph floor.\n- Two forced-config exploration sweeps.\n\n**Inherited (labelled, not re-measured):**\n- m_128 and m_256 PMC/ISA from 3f155afd: `cases/*/profile/inherited_3f155afd/`.\n- m_1024 partial PMC from 049413f3 via 3f155afd.\n- 049413f3 PIPE-2 / register-path PMC copied as `profile/parent_baseline_049413f3/` for before/after comparison only.\n\n**Session attempt 2 (resume):**\n- The canonical source in the worktree is byte-identical to 0ea69bb (`git diff 0ea69bb -- <source>` is empty), so every attempt-1 measurement above still describes this commit.\n- No GPU work was repeated.\n- `incremental_diff.patch` was restored from `analysis/deltas/3f155afd_to_0ea69bb.patch`, which is identical to `git diff 3f155af 0ea69bb` and covers both KEEP commits (8a2edc8, 0ea69bb).\n- The m_4096 direction text was aligned with \u00a76.\n\n## 0. What changed across the KEEP sequence (2 commits)\n\n| commit | mechanism | cases |\n|---|---|---|\n| **8a2edc8** (iter-6) | **Small M \u2192 `gemm_lds_splitk_kernel`.** BN=16 tiles with 4 (8 at M=8) K-split waves, each with a wave-private 4-stage LDS ring. **EDGE** handles M < 16: the A/C descriptors are sized to M, A loads past M are skipped, and C stores past M are dropped. **PARTIAL** store: VEC \u2265 2, only BM\u00b7BN/VEC threads store. | m_1, 2, 4, 8, 16, 32, 64 (previously `gemm_splitk_kernel` / `gemm_kernel`) |\n| **0ea69bb** (iter-9) | **`gemm_lds_kernel` PIPE 3.** The next tile's LDS-DMA loads are spread evenly over the MFMA slots, `N_PRE` before the mid-tile barrier and the rest after. Optional `s_setprio` (PRIO) and `sched_barrier` fences (PIN). | m_512 (N_PRE 4, PIN 1), m_2048 (N_PRE 2, PRIO 1), m_4096 (N_PRE 2, PIN 1) |\n| \u2014 | Binaries unchanged: m_128 and m_256 (EDGE/PARTIAL are compiled out), m_1024 (still PIPE 2) | m_128, m_256, m_1024 |\n\n**Hot-graph A/B, canonical vs parent 3f155afd, same GPU** (`tools/bench/graph_ab.txt`):\n\n| case | per-call parent \u2192 canon (\u00b5s) | one-call parent \u2192 canon (\u00b5s) | harness 3f155afd bundle \u2192 now (\u00b5s) |\n|---|---|---|---|\n| m_1 | 4.86 \u2192 **2.98** | 15.68 \u2192 14.68 (13.96 in a 2nd sweep) | 15.24 \u2192 13.93 |\n| m_2 | 5.00 \u2192 **3.03** | 14.64 \u2192 15.04 (one-call jitter \u00b10.5) | 15.36 \u2192 13.99 |\n| m_4 | 5.06 \u2192 **3.03** | 14.80 \u2192 13.96 | 15.55 \u2192 14.14 |\n| m_8 | 5.31 \u2192 **3.11** | 15.12 \u2192 14.04 | 15.62 \u2192 14.30 |\n| m_16 | 5.63 \u2192 **3.49** | 15.40 \u2192 13.92 | 15.81 \u2192 14.49 |\n| m_32 | 6.65 \u2192 **3.55** | 16.80 \u2192 13.96 | 16.98 \u2192 14.40 |\n| m_64 | 8.59 \u2192 **3.88** | 18.76 \u2192 14.08 | 18.88 \u2192 14.43 |\n| m_128 / m_256 | 5.07 / 6.39 \u2192 5.02 / 6.25 (unchanged) | = | 15.84 / 17.11 \u2192 15.72 / 17.23 |\n| m_512 | 10.55 \u2192 **9.79 (\u22127.2%)** | 21.32 \u2192 20.56 | 21.4 \u2192 20.88 |\n| m_1024 | 15.45 \u2192 15.46 (unchanged) | = | 25.7 \u2192 25.48 |\n| m_2048 | 22.91 \u2192 **21.47 (\u22126.3%)** | 32.16 \u2192 31.56 | 32.3 \u2192 31.10 |\n| m_4096 | 34.99 \u2192 **33.03 (\u22125.6%)** | 44.76 \u2192 43.14 | 44.3 \u2192 41.94 |\n\n**Harness sum:** 269.9 \u2192 **252.0 \u00b5s (\u22126.6%)**. All changed binaries pass the fp32-reference check with the same max_err as the parent (`tools/isa/m_*/stdout.log`).\n\n## 1. Bottom line\n\n1. **Small M (\u2264 64, 7 of 13 cases) is now at the launch floor.**\n   - A trivial elementwise kernel captured the same way costs **13.80 \u00b5s** one-call and 1.88 \u00b5s per call (`tools/bench/graph_floor.txt`).\n   - Canonical M \u2264 64 runs 13.9\u201314.1 \u00b5s one-call and 13.93\u201314.49 \u00b5s harness. The remaining headroom is **\u2264 0.1\u20130.7 \u00b5s per case (\u2264 1\u20135%)**.\n   - The PMC shows a latency-dominated short stream:\n     - MFMA \u2264 2% busy; TA \u2264 22% per active CU.\n     - 45\u201358% of wave cycles are spent waiting; L1\u2192L2 latency is 480\u2013520 cycles.\n     - B is re-fetched from fabric on every launch (fabric \u00f7 unique 1.0\u20131.1).\n     - At M \u2264 16 only 64 of 256 CUs have work.\n   - More waves per WG were **measured slower** (SPLIT_K 8/16, BK 32). Do not spend more turns here.\n2. **Every LDS-staged kernel is still on the feed law: WG-level L2\u2192LDS feed F \u2248 16\u201318 TB/s.**\n\n   | case | F (TB/s, trace) | FLOP/B | TFLOP/s |\n   |---|---|---|---|\n   | m_512 | 17.4 | 21.3 | 372 |\n   | m_2048 | 18.3 | 42.7 | 781 |\n   | m_4096 | 16.2 | 64 | 1036 |\n\n   - PIPE 3 won by **overlap, not bytes**. L1\u2192L2 \u00f7 WG request stays 1.000 and fabric \u00f7 unique is unchanged (2.0 / 2.29 / 2.46).\n   - At m_4096 vs the 049413f3 PIPE-2 PMC: MFMA busy 40.1 \u2192 43.0%, TA busy 48 \u2192 54%. At m_2048: MFMA 26.9 \u2192 28.4%.\n3. **The large-M remaining gap is TA/MFMA under-utilization.**\n   - At m_4096, TA is 54% busy at ~20 TA cycles per 1 KiB LDS-DMA, i.e. ~51 B/clk/CU when busy. The CU could feed ~1.8\u00d7 more if TA stayed busy.\n   - MFMA is 43%, and waves spend 42% in `wait_inst` and 14.5% in LDS-wait.\n   - The kernel is limited by load-issue / barrier coupling, not by a saturated unit.\n4. **The equal-weight objective is now dominated by m_128 \u2026 m_4096.**\n   - m_4096 alone is 17% of the harness sum.\n   - The best remaining levers: raise F at M \u2265 1024 (D-L4\u2032), and bigger tiles plus inter-WG split-K at m_128 / m_256 (D-K5).\n\n## 2. Grouping decision\n\n| group | cases | representative(s) | transferable evidence and rationale |\n|---|---|---|---|\n| **SK-small** (8a2edc8) | m_1, m_2, m_4, m_8, m_16, m_32, m_64 | **m_1** (EDGE, BK 128), **m_8** (EDGE, SPLIT_K 8, unrolled), **m_16** (full tile, 64 WGs), **m_64** (256 WGs): all with own PMC | One template, but three distinct binaries (EDGE/BK128, EDGE/SK8, full/BK64) and two grid regimes (64 vs 256 WGs), so each got its own representative. m_2 and m_4 reuse m_1: identical config row and EDGE path, only constants differ, and own traces 4.38 / 4.32 vs 4.28 \u00b5s. m_32 reuses m_16: identical row, 128 WGs, trace 4.40 vs 4.28. All four representatives give the same verdict (latency-bound, at the floor), which validates the reuse. |\n| **L3** (0ea69bb, PIPE 3) | m_512, m_2048, m_4096 | each profiled independently | Three different PIPE-3 tunings (N_PRE / PRIO / PIN) and tiles; no reuse needed. |\n| **K** (unchanged) | m_128, m_256 | inherited 3f155afd own PMC | Binary unchanged. Fresh trace and A/B match the parent (5.02 vs 5.07, 6.25 vs 6.39 \u00b5s). |\n| **L2** (unchanged) | m_1024 | inherited 049413f3 partial PMC | Config is PIPE 2, byte-identical. A/B 15.46 vs 15.45 \u00b5s. |\n\nNo pooled multi-case counters are used. Exploration timings on forced configs live in `tools/bench/graph_explore_*.txt` and are never presented as a case profile.\n\n## 3. Key measurements (this session; raw under `cases/*/profile/pmc_g*/`, normalized in `profile/normalized_metrics.json`)\n\nThe GRBM-normalized busy percentages are lower bounds. The implied clock exceeds 2.4 GHz on short kernels because GRBM includes dispatch overhead.\n\n| metric | m_1 | m_8 | m_16 | m_64 | m_512 | m_2048 | m_4096 |\n|---|---|---|---|---|---|---|---|\n| trace (\u00b5s) / WGs \u00d7 waves | 4.28 / 64\u00d74 | 3.88 / 64\u00d78 | 4.28 / 64\u00d74 | 4.84 / 256\u00d74 | 11.56 / 256\u00d74 | 22.0 / 256\u00d78 | 33.16 / 256\u00d78 |\n| wait-any / wait-inst / active % | 45 / 23 / 31 | 56 / 32 / 12 | 58 / 11 / 31 | 56 / 14 / 32 | 32 / 25 / 44 | 38 / 28 / 34 | 26 / 42 / 32 |\n| MFMA busy % | 0.5 | 0.5 | 0.5 | 1.8 | 10.2 | 28.4 | 43.0 |\n| LDS-wait % | 0.7 | 1.0 | 0.4 | 0.4 | 3.5 | 8.7 | 14.5 |\n| WG feed (TB/s) / FLOP/B | 2.1 (L1\u2192L2) / 0.8 | 3.2 / 5.3 | 3.9 / 8 | 13.9 / 8 | 17.4 / 21.3 | 18.3 / 42.7 | 16.2 / 64 |\n| L2 hit % / fabric \u00f7 unique | 7 / 1.01 | 30 / 1.06 | 44 / 1.11 | 81 / 1.40 | 83 / 2.46 | 83 / 2.29 | 80 / 2.00 |\n| L1\u2192L2 latency (cycles) | 522 | 517 | 503 | 482 | 426 | 435 | 382 |\n| TA busy % per active CU / cycles per KiB | 14 / 21.7 | 17 / 24.0 | 22 / 22.8 | 21 / 22.7 | 39 / 20.6 | 53 / 19.7 | 54 / 20.1 |\n\n## 4. Interpretation\n\n**Small M \u2014 why ~3 \u00b5s per call, and why it cannot shrink much.**\n- Each of the 64 active CUs streams 128\u2013256 KiB through a 4-deep per-wave ring.\n- The stream is short: 8\u201316 K-tiles per wave.\n- Prologue fill, epilogue fold through LDS, and dispatch dominate. MFMA is ~0.5%.\n- Doubling the bytes in flight per CU (SPLIT_K 8) helps only at M=8 (EDGE, half the A loads). At M=16/32/64 it costs +0.1 \u00b5s, and 16 waves cost +0.4\u20130.7 \u00b5s.\n- m_64 fills all 256 CUs with 4\u00d7 the L2 requests yet costs only +0.4 \u00b5s per call over m_16. Time is dominated by fixed latency, not bytes.\n- In the harness the kernel is hidden behind the ~10.5\u201312 \u00b5s launch floor: canonical one-call \u2212 empty one-call is \u2248 0.1\u20130.3 \u00b5s.\n\n**Large M \u2014 PIPE 3 confirms the feed ceiling is an overlap ceiling.**\n- Spreading loads across MFMA slots raised TA busy (48 \u2192 54%) and MFMA busy (40 \u2192 43%) together, and F rose slightly at m_2048 (17.8 \u2192 18.3 TB/s).\n- Neither unit is near saturation. TA at ~20 cycles per KiB gives ~51 B/clk/CU when busy (\u2248 31 TB/s aggregate at 2.4 GHz), so F \u2248 16\u201318 TB/s is about 55% of the LDS-DMA issue ceiling.\n- The limiter is how often the TA is idle. Waves are in `wait_inst` 28\u201342% of cycles (vmcnt/lgkmcnt), lock-stepped by the per-tile `s_barrier`.\n- KEEP-comment data: deeper rings (STAGES 5/6) and BK 128 were slower, and inter-WG split-K lost at 512 / 1024 / 2048.\n\n**m_128 / m_256.** Unchanged binaries on the same ~16.6 TB/s feed law (inherited 3f155afd PMC). The new sweep (`graph_explore_midM.txt`) shows that smaller tiles with more WGs lose 1.2\u20135 \u00b5s. Only bigger tiles (higher FLOP/B) can help.\n\n## 5. Remaining headroom (harness \u2248 hot one-call graph; floor 13.80 \u00b5s)\n\n| case(s) | harness now (\u00b5s) | lever | kernel per-call now \u2192 target (\u00b5s) | harness after (\u00b5s) | gain | evidence |\n|---|---|---|---|---|---|---|\n| m_1 \u2026 m_64 | 13.93\u201314.49 | none worth a turn | 3.0\u20133.9 \u2192 \u2265 1.9 floor | \u2265 13.8 | \u2264 1\u20135% | measured floor and negative sweeps |\n| m_4096 | 41.94 | D-L4\u2032: raise F via load/MFMA decoupling | 33.0 \u2192 ~26\u201328 | ~35\u201337 | \u221212 to \u221216% | TA 54%, MFMA 43%, wait-inst 42% (model) |\n| m_2048 | 31.10 | D-L4\u2032 | 21.5 \u2192 ~18 | ~27.5 | \u221210% | TA 53%, MFMA 28% (model) |\n| m_1024 | 25.48 | D-L4\u2032; PIPE 3 measured no gain (KEEP comment) | 15.5 \u2192 ~13 | ~23 | \u22128% | inherited PMC (model) |\n| m_256 / m_128 | 17.23 / 15.72 | D-K5: bigger tile + inter-WG split-K | 6.3 \u2192 ~4.5\u20135 / 5.0 \u2192 ~4 | ~15.5\u201316 / ~14.8 | \u22127 to \u221210% / \u22126% | feed-law model; smaller tiles measured to lose |\n| m_512 | 20.88 | \u2014 | 9.8 \u2192 ~9 | ~20 | \u2264 \u22124% | feed-bound; split-K lost (KEEP comment) |\n\n## 6. Prioritized directions\n\n**1. D-L4\u2032 \u2014 decouple LDS-DMA issue from the MFMA/barrier cadence in `gemm_lds_kernel`** (m_4096, m_2048, m_1024).\n- Goal: keep the TA continuously busy.\n- Options:\n  - (a) Producer-wave specialization: 1\u20132 waves of the WG issue all `buffer_load \u2026 lds` for upcoming stages, while the other waves only `ds_read` + MFMA. Sync is per-stage via an LDS flag or a split barrier, instead of every wave issuing loads and then waiting at a full `s_barrier`.\n  - (b) Per-wave-private ring (the `gemm_lds_splitk_kernel` pattern) for the B operand only, where each wave's B slice is private (WARPS_N split).\n- Success criterion: TA busy > 70%, F > 21 TB/s.\n- Caveat (from 3f155afd): barrier removal alone did not raise F in the split-K kernel. The win must come from keeping **more** loads in flight per CU, without deeper LDS stages (STAGES 5/6 were slower).\n\n**2. D-K5 \u2014 bigger WG tiles at m_128 / m_256, kept at 256 WGs via inter-WG split-K** inside `gemm_lds_splitk_kernel`.\n- m_128: 32\u00d732 with 2 K-groups across WGs (FLOP/B 10.7 \u2192 16).\n- m_256: 32\u00d764 or 64\u00d764 with 2\u20134 K-groups (FLOP/B 16 \u2192 21\u201332).\n- Reuse `gemm_splitk_kernel`'s last-arriver workspace. Its tail (~0.5\u20131 \u00b5s) must stay below the feed savings (~1\u20132 \u00b5s).\n- This session measured the opposite direction (smaller tiles) as losing 1.2\u20135 \u00b5s, which supports the feed-law premise.\n\n**3. Reduce LDS fragment traffic per FLOP at m_4096** (LDS-wait 14.5%, rising with M).\n- Wave tile 64\u00d764 (2\u00d72 warps on 128\u00d7128) instead of 64\u00d732 (2\u00d74) cuts `ds_read` bytes per FLOP by 33%.\n- Risk: 4 waves per CU, 1 per SIMD, so latency hiding drops.\n- Combine with 1 (a producer wave would restore wave count).\n- Inherited D-L5 (MFMA 32\u00d732\u00d716) mainly cuts instruction count, not LDS bytes, for the same wave tile, so expect \u2264 1\u20132 \u00b5s.\n\n**4. Small M: stop.** The harness is within 0.1\u20130.7 \u00b5s of the empty-kernel graph floor.\n- Measured negatives this session: SPLIT_K 8 at M=16/32/64, 16-wave (BK 32) rings, BK 32 at M=64.\n- Previously measured negatives (KEEP comment): SPLIT_K 2, 6\u20138 stage rings.\n\n**Not recommended (measured):**\n- 16\u00d716 / 16\u00d732 / 32\u00d716 tiles at m_128 / m_256 (this session).\n- PIPE 3 at m_1024, STAGES 5/6, BK 128 at 2048/4096, and inter-WG split-K for M \u2265 512 (KEEP comment, 0ea69bb).\n- Inherited not-recommended items from 3f155afd still stand.\n\n## 7. Tooling and provenance\n\n- **rocprof-compute:** re-attempted via the supplied `tools/rocpc_profile.py` (m_64, 150 s timeout). It exited **3** (\"installed, but its Python deps are not available\"; `tools/rocpc_preflight/preflight.log`).\n- **Fallback, recorded in `commands.jsonl`:**\n  - rocprofv3 `--kernel-trace` (200 launches, first dropped).\n  - 9 small PMC groups of 2\u20134 counters each: one group per rocprofv3 process, separate output dir per pass, 150 s timeout, no retries (`tools/run_profile.sh`, retargeted from 3f155afd).\n  - All 13 traces and 63 PMC passes exited 0.\n- **Adapter:** `tools/case_adapter.py` (copied from 3f155afd; it imports the canonical `kernel.py` read-only).\n- **Normalizer:** `tools/norm_r.py`, updated for the new config tables and EDGE byte accounting. `tools/show_norm.py` prints summaries.\n- **Members and case files:** `tools/write_members_0ea.py` writes the member / inherited metrics; `tools/write_case_md.py` generates the case analyses.\n- **A/B:** `tools/graph_ab.py` against `tools/parent_src/parent_3f155afd_src.py` (a `git show` export).\n- **Floor:** `tools/graph_floor.py`. Its stdout was transcribed to `bench/graph_floor.txt`. The read_B row uses an unoptimized torch reduction and is not a byte floor.\n- **ISA:** `FLYDSL_DUMP_IR=1 FLYDSL_DEBUG_DUMP_ASM=1` into `tools/isa/m_*`, summarized in `tools/isa/loop_summary.txt`. The summarizer was patched to report fully unrolled loops (M=8).\n- **Caveats:**\n  - Graph one-call medians jitter by \u00b10.5 \u00b5s between sweeps (m_1: 14.68 vs 13.96); per-call medians are stable to \u00b10.05 \u00b5s.\n  - Kernel-trace times exceed graph per-call by ~1\u20131.3 \u00b5s at small M.\n  - F is an empirical fit, not a hardware spec."
  },
  "rendered_refs": [],
  "run_id": "58a1e342578b4d949f55fa177fb9089c",
  "schema_ref": "schema:sha256:090fe777e98b33e0487d53307df1bf72bafed2954a60826c3f7b84eb34387481",
  "schema_version": 2,
  "seq": 13,
  "status": "complete",
  "supersedes": ""
}

Known limitations

  • forge-loop records neither the kernel language's version nor the dtype, so identity.backend_version and identity.dtype stay absent for now.
  • The run a log belongs to and a kept candidate's KB record are parsed from lines forge-loop prints. forge-loop and the exporter share loop_log.py.
  • A session that gives no record is collected with KernelForge's (no agent summary) fallback.
  • KernelForge logs naive local times, which collection reads as UTC: right in a UTC container, off by the host's offset elsewhere.

kernelforge export-runs writes an empty raw-logs-not-collected.log beside its document. Every KernelForge Experience links that one empty file as raw_logs. A document exported before this change has no raw_logs, so its runs must be exported again before they are collected.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Code metrics gate: PASSED

Judged: the 55 files this change touches. Files it does not touch never fail it; anything found there is listed under Outside this change for information.

Dimension Limit New Worse Baseline out of date Baseline entries vs base
Cyclomatic complexity fails > 20 0 0 0 6 +0
Cognitive complexity fails > 30 0 0 0 54 +0
Function length (lines) fails > 50 0 0 0 544 +0
Nested block depth fails > 4 0 0 0 44 +0
Parameter count fails > 5 0 0 0 80 +0
Module length (lines) warns > 800, fails > 1200 0 0 0 44 +0
Duplicated lines per file fails > 0 0 0 0 95 +0
Dead code (vulture) fails > 0 0 0 0 2 +0
Directory size (entries) fails > 50 0 0 0 2 +0
Check Findings Effect
Comments 0
Broad catch without a reason 0
English only 0
Production code imports test code 0
Repeated literals 0
A1 Private name of another package 0
A8 Flag argument 0
A9 Tuple return 0
A10 Raw vocabulary comparison 0
A11 Hidden global write 0
A12 Pure forwarder 0
A12 Single-implementation abstraction 0

Module length warning: over 800 lines, within the 1200-line failure limit (not failing): 2

Module Lines Limit
src/kernelforge/loop/lessons.py 1044 warns > 800
src/kernelforge/orchestrator/agent.py 954 warns > 800

Already over at the base (backlog, not this change's): 4

Unit Dimension Base Now
src/kernelforge/loop/runner.py:5776 IterationLoop._collect_session_candidate Cognitive complexity 44 44
src/kernelforge/loop/runner.py:5382 IterationLoop._rule_on_attempt Cognitive complexity 38 38
src/kernelforge/loop/runner.py:1 Module length (lines) 6510 6492
src/kernelforge/loop/task_preparer.py:1 Module length (lines) 1557 1557
24 findings in files this change does not touch

Outside this change (informational): 24

Unit Dimension Baseline Now
src/hyperloom/orchestrator/actions/executors/_integrate_apply.py ApplyStageMixin._stage_apply Cognitive complexity absent 32
src/hyperloom_kb/collect/mapping.py _experience Cognitive complexity absent 31
src/kernelforge/cli/forge_loop_command.py forge_loop Cognitive complexity absent 31
src/hyperloom/orchestrator/loop/_keep_patch_column.py AgentKeepStagingMixin._stage_agent_keep Parameter count absent 6
src/hyperloom/orchestrator/phases/kernel.py KernelPhase._record_geak_delegation_timeline Parameter count absent 7
src/hyperloom/orchestrator/loop/_baseline_failures.py BaselineFailureMixin.handle_unpromotable_result Function length (lines) absent 55
src/hyperloom/orchestrator/loop/_resume_recovery.py ResumeRecoveryMixin._resume_recover_pending_warm_replay Function length (lines) absent 75
src/hyperloom_kb/http_service.py Module length (lines) absent 1525
src/kernelforge/cli/forge_loop_command.py Module length (lines) absent 2003
src/hyperloom/orchestrator/specialists/dispatch.py SpecialistDispatchCollaborator.warm_specialist_params Cognitive complexity 93 94
src/hyperloom/inference_optimizer/cli/preflight.py _probe_rocm_build Function length (lines) 54 57
src/hyperloom/inference_optimizer/breakdown/schema.py Module length (lines) 2681 2686
src/hyperloom/inference_optimizer/cli/__init__.py Module length (lines) 2569 2739
src/hyperloom/orchestrator/actions/executors/_kv_metrics.py Module length (lines) 1578 1579
src/hyperloom/orchestrator/actions/executors/roofline.py Module length (lines) 1303 1305
src/hyperloom/orchestrator/kernel/conc_sweep.py Module length (lines) 1429 1449
src/hyperloom/orchestrator/phases/kernel.py Module length (lines) 4102 4199
src/hyperloom/orchestrator/phases/prelude.py Module length (lines) 2504 2506
src/hyperloom/orchestrator/prompts/prompt_builder.py Module length (lines) 1205 1212
src/hyperloom/orchestrator/prompts/specialist_prompt_builder.py Module length (lines) 2556 2567
src/kernelforge/fusion/command.py Module length (lines) 1584 1708
src/kernelforge/fusion/validate.py Module length (lines) 1513 1514
src/kernelforge/gemm_tune/tuners/_aiter_dense_common.py Module length (lines) 1399 1463
src/kernelforge/loop/insession_gate.py Module length (lines) 1218 1260

Verdict computed by the gate scripts in /home/runner/work/_temp/code-metrics-gate, not by this tree's copy.

Thresholds and sources: [tool.hyperloom.code_metrics] in pyproject.toml. Tools: complexipy 8.0.1, jscpd 5.4.1, ruff 0.16.2, vulture 2.16.

@amd-ama10002-2

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

Copy link
Copy Markdown

Automated review started for head a59194e. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/39556525071

@github-actions

Copy link
Copy Markdown

Automated review: no verdict applied -- the review job ended with result 'failure'. Comment /review to retry. Run: https://github.com/AMD-AGI/Hyperloom/actions/runs/39556525071

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Export error handling can omit runs or abort collection, and the measurement contract mislabels supported timing aggregates.

3 open issues
What changed in this PR

Adds KernelForge run collection to the Experience KB, preserving candidate metadata and planning evidence without storing raw logs.

Changes:

  • Adds export-runs, a packaged declaration, and a collection mapping.
  • Records plan summaries and implementer work logs.
  • Preserves ignored driver repairs and shares workspace-output handling.
File Description
src/​kernelforge/​tests/​tools/​test_lane_aiter_cache.py Updates the lane callback signature.
src/​kernelforge/​tests/​test_workspace_policy.py Tests output-directory hiding.
src/​kernelforge/​tests/​test_workspace_new_paths.py Tests reasoning-log workspace safety.
src/​kernelforge/​tests/​test_task_preparer_driver_lifecycle.py Tests ignored repairs and failed commits.
src/​kernelforge/​tests/​test_summary_line.py Tests summary and work-log conventions.
src/​kernelforge/​tests/​test_run_export.py Tests export and collection behavior.
src/​kernelforge/​tests/​test_loop_runner.py Tests log paths and scoring inputs.
src/​kernelforge/​tests/​test_lane_session_guarantees.py Tests lane permissions and gate scoring.
src/​kernelforge/​tests/​test_lane_plan_persistence.py Tests log preservation during replanning.
src/​kernelforge/​tests/​test_forge_orchestration.py Tests planner summary instructions.
src/​kernelforge/​tests/​test_fanout.py Updates the mock callback signature.
src/​kernelforge/​tests/​test_codex_backend.py Tests reasoning-log session wiring.
src/​kernelforge/​run_export/​units.py Defines shared export records.
src/​kernelforge/​run_export/​forge_loop.py Converts archived candidates into entries.
src/​kernelforge/​run_export/​files.py Provides file discovery and bounded reads.
src/​kernelforge/​run_export/​document.py Combines entries across log roots.
src/​kernelforge/​run_export/​command.py Implements the export command.
src/​kernelforge/​run_export/​archive.py Extracts candidate artifacts and evidence.
src/​kernelforge/​run_export/​__init__.py Introduces the export package.
src/​kernelforge/​orchestrator/​summary_line.py Defines summaries and work-log paths.
src/​kernelforge/​orchestrator/​plan_prompts.py Centralizes planner prompts.
src/​kernelforge/​orchestrator/​orchestration.py Uses the extracted planner prompts.
src/​kernelforge/​orchestrator/​agent.py Wires work logs and grouped scoring.
src/​kernelforge/​loop/​task_preparer.py Allows unstaged driver repairs.
src/​kernelforge/​loop/​runner.py Passes log paths and preserves logs.
src/​kernelforge/​loop/​agent_extras.py Filters optional callback arguments.
src/​kernelforge/​llm/​workspace_policy.py Shares output-directory hiding.
src/​kernelforge/​kernel_rewrite_controller/​worktree.py Reuses the shared output helper.
src/​kernelforge/​evaluation/​scoring.py Adds the GateScoring value type.
src/​kernelforge/​cli/​lanes.py Forwards lane work-log paths.
src/​kernelforge/​cli/​__init__.py Registers export-runs.
src/​hyperloom_kb/​tests/​test_collect_kernelforge.py Tests the packaged collection contract.
src/​hyperloom_kb/​mappings/​kernelforge-v1.yaml Maps entries into Experiences.
src/​hyperloom_kb/​declarations/​kernelforge-run-v1.yaml Defines KernelForge Experience fields.
scripts/​code_metrics_baseline.txt Updates reduced size baselines.
docs/​reference/​experience-kb-collect.md Documents KernelForge collection.
docs/​kernelforge/​reference/​cli.md Documents export usage.
docs/​kernelforge/​how-to/​autonomous-loop.md Documents summaries and work logs.

Review effort: Balanced

Comment thread src/kernelforge/run_export/archive.py Outdated
Comment thread src/kernelforge/run_export/forge_loop.py
Comment thread src/kernelforge/run_export/forge_loop.py Outdated
@amd-ama10002-2
amd-ama10002-2 marked this pull request as ready for review October 21, 2026 08:27
@amd-ama10002-2
amd-ama10002-2 force-pushed the feat/kernelforge-experience-kb branch from 00703b8 to 15aba9e Compare October 22, 2026 17:15
@amd-ama10002-2

Copy link
Copy Markdown
Collaborator Author

TL;DR: CI is red here because a test is currently broken on main, not because of this PR. Other PRs fail the same way, and #2113 fixes it.

What fails. One test fails, in py3.10 shard 2/6 and py3.11 shard 4/6: test_a_candidate_after_resume_still_sees_the_kept_source_layer in src/hyperloom/inference_optimizer/tests/test_coordinator_async_batch2_unit.py. The error is AttributeError: module 'hyperloom.orchestrator.actions.executors.integrate_patch' has no attribute '_git_head_sha'.

Why it's main. Two PRs merged six minutes apart, and together they break this test:

  • #2091 merged at 11:46 UTC and added the test, which calls integrate_patch._git_head_sha.
  • #2098 merged at 11:52 UTC and moved _git_head_sha into executors/_git.py.

At main's tip (8b0ae5500), the test still calls ip._git_head_sha, but integrate_patch.py no longer defines it. This PR changes neither file. #2075 and #2116 fail exactly the same four checks.

The two coverage jobs. These are red only because their Enforce shard results step repeats the two shard failures; total coverage is 93.2%. Their fail-under and diff-coverage gates are skipped whenever a shard fails, so they haven't run on this head. On the pre-rebase head 00703b87f, every check passed, including diff coverage at 98% of changed lines. The conflict resolutions in the rebase touched only import lines and code-metrics baseline entries.

Everything else passes: the other ten test shards, code-metrics, ruff, pylint, mypy, import-linter, bandit, CodeQL and the docs build.

Next. #2113 is tests-only, green, and waiting for review. Once it merges, I'll rebase onto main and push. Re-running the failed jobs won't help on its own, because a re-run reuses the old merge commit.

@zili-amd

Copy link
Copy Markdown
Collaborator

Thank you @amd-ama10002-2 for the contribution to Hyperloom, here is the PR review of 15aba9e.

This PR turns each candidate a forge-loop run archived into an Experience through kernelforge export-runs and the kernelforge-v1 mapping, with each lane's factual record now taken inside its round and carried in lane_queue.json. Thank you for the round-two fixes: the lane record turn, the code-metrics result and the description all check out against the code, and dropping llm_backend/llm_model is fine, since provenance.extra.meta keeps agent_provider and agent_model. A pass against AGENTS.md found four things that still block it.

1. The exporter re-types formats and paths other KernelForge modules own (AGENTS.md, Clean design).

  • What is wrong: src/kernelforge/run_export/archive.py:34-35 copies the critic-fallback markers orchestration.py writes (:1540, :1549). archive.py:131, :151-159 and :193 rebuild the orchestration, candidates, handoffs, lessons and run-state layout, whose writers are runner._orchestration_root, LessonStore, the handoff store and the run-state store. src/kernelforge/run_export/forge_loop.py:42-57 copies "forge_experiments" (FORGE_LOOP_OUTPUT_DIRNAME), the lane rationale (runner.py:5645), the gate tag (agent.py:351), the event types and the stdout lines. The package already imports owners elsewhere (MERGE_PLAN_PREFIX, resolve_loop_identity, read_summary), and .importlinter has no contract against these.
  • What it costs: a change at any of those writers silently misclassifies or drops candidates while every test stays green, because the tests write the same literals by hand. The Implementer: copy removed in this PR broke exactly that way when feat(kernelforge): relay search across agent profiles #1945 changed the print.
  • What to change: import FORGE_LOOP_OUTPUT_DIRNAME. Lift the archive layout into pure path functions that the writers and the exporter share; LessonStore and the handoff store create their directories in their constructors (lessons.py:753-757, handoffs.py:82), so the exporter cannot instantiate them on an archive it reads. Lift the lane rationale, gate tag, critic-fallback markers, event types and stdout lines into constants beside their writers, and use them on both sides.

2. The declaration's decision values are a second copy of KernelForge's decision labels (AGENTS.md, Code shape: a closed vocabulary is one Enum or registry entry).

  • What is wrong: src/hyperloom_kb/declarations/kernelforge-run-v1.yaml:119-132 lists the 13 labels that runner._decision_label (runner.py:6440-6456) and the literal decision_label sites produce, with no shared source and no test tying them. hyperloom_kb/schema.py:447 rejects any other value.
  • What it costs: a collect dry run of the fixture with decision REVERT_NOISE skips the candidate with "schema validation failed: outcome.decision must be one of: ...". A label KernelForge adds later skips every such candidate, and nothing in CI notices.
  • What to change: export KernelForge's decision labels as one set and add a test asserting the declaration's values equal it.

3. session_sink["lesson"] encodes three states in an optional dict key (AGENTS.md, Code shape).

  • What is wrong: src/kernelforge/loop/runner.py:5652 sets it for lanes, and :4024 branches on absent (this iteration ran the session), "" (the lane gave no record) and text. AGENTS.md rules out a dict whose optional keys stand for states, and lessons.SummaryOutcome already models "a record, or the reason there is none".
  • What it costs: because the lane's _session returns only the text (runner.py:1964-1967), the lane's real reason for having no record is lost, and the fallback lesson records "the lane's session gave no record in its round" instead.
  • What to change: carry the lane's SummaryOutcome on the queued candidate, persisting its reason as well, and pass it to _record_lesson as a typed argument.

4. The lane session_sink goes through signature introspection that only test doubles need (AGENTS.md, Trust the caller; Finish the replacement; Comment below the local average).

  • What is wrong: src/kernelforge/loop/runner.py:1962 and src/kernelforge/cli/lanes.py:196 call declared_extras for session_sink. The only production lane factory (src/kernelforge/cli/relay.py:69-70) builds every lane agent with make_agent_fn, which declares it, and no entry point lets other code inject one. lanes.py:195 and the agent_extras.py docstring describe agents "written before the sink existed", which narrates history.
  • What it costs: a fallback kept only for old internal call shapes, and it hides a real failure: a lane agent that dropped the sink would silently lose its lesson.
  • What to change: pass session_sink= directly; update the test lane agents that lack it (test_loop_runner.py:623, :639, :1380; test_fanout.py:694; tools/test_lane_aiter_cache.py:89); delete test_lane_session_guarantees.py:157-186, which pins the introspection; drop the narrating comment and docstring sentence.

The failing test shards and coverage jobs are not from this PR. They fail on src/hyperloom/inference_optimizer/tests/test_coordinator_async_batch2_unit.py::test_a_candidate_after_resume_still_sees_the_kept_source_layer, which fails the same way on main at 8b0ae55 after #2098; coverage itself is 93.23%. A rerun once main is fixed should clear them.

Reviewed head 15aba9e against merge base 8b0ae55. Ran: pytest on the 13 touched test files (767 passed); the description's example regenerated from the test fixture (matches the body and the declaration exactly); the failing CI test at the merge base and at the head (fails identically); a collect dry run with an unlisted decision label; AGENTS.md checked rule by rule against the diff.

@amd-ama10002-2

Copy link
Copy Markdown
Collaborator Author

#2037 (comment)

Replies below use the same numbers as that review. The changes are on 4d3570deb.

1. The exporter reads the archive layout and the log formats from the modules that write them.

experiments_layout names the forge_experiments paths, file names and timestamp format, and the stores and the runner build those paths from it. loop_log renders the banner, the kernel line, the iteration header and the experience-KB line, and the exporter matches the patterns compiled from the same pieces. The iteration event types sit beside make_event, the lane rationale beside the fan-out writer, the gate tag in gate_tag, and the critic-fallback plan in critic_fallback. The export fixtures are built through those writers, and the exported units are unchanged. The Analysis generation layout (analysis/<commit>/generation-NNN/report.md and analysis_bundle) was left out: it has no single owner. The loop's own readers of event types and paths were left as they are, because those readers already run against the real writers in the loop tests. runner.LOOP_ARTIFACT_ROOT was left as it is.

2. The declaration's decision values are the one DecisionLabel set.

kernelforge.loop.decision_labels defines the 13 labels the forge-loop records, and runner.py takes every label it produces, including _decision_label and _LABELS_WITHOUT_A_MEASURED_NEGATIVE, from DecisionLabel. test_decision_labels.py asserts that declaration.field("outcome", "decision") on kernelforge-run-v1 equals DECISION_LABELS.

3. A lane without a record gets a fallback lesson that names why.

LaneResult.lesson is the lane's SummaryOutcome, and run_lanes stores it as the session returned it. lane_queue.json carries lesson_reason beside the lesson text. _record_lesson takes that outcome as lane_record; when the lane has no narrative, the fallback lesson and the printed line both name the reason, for example "provider cannot resume the session". An old queue entry with neither field restores with the reason that it held no record: "the lane queue held no record for this lane".

4. session_sink is passed directly.

The lane session and the lane factory hand session_sink to the lane agent directly instead of through declared_extras. The test doubles were updated to accept it, and the test that pinned the old introspection, test_a_lane_does_not_pass_a_session_sink_to_an_agent_that_omits_it, was removed. A lane agent that does not accept the sink now fails; the only production factory accepts it.

ruff format --check . and ruff check . passed. lint-imports --no-cache kept all 10 contracts. The combined tests were 1172 passed and 42 failed under -n auto (NFS git diff timeouts at 300s, fake-codex timeouts at 10s, and 2s Codex timeouts); those 42 passed when rerun with -n 4. code-metrics, using jscpd 5.4.1, reports no new or worse unit and no out-of-date baseline; runner.py is 6492 physical lines, and the baselines for _record_lesson and orchestration.py are now 105 and 1744. The local gate still exits 1 because four baseline entries this branch does not touch are higher than origin/main at 7eeb1a1ec3, which lowered them after merge base 8b0ae5500; merging the baseline file with that main is clean and does not raise those entries.

@amd-ama10002-2

Copy link
Copy Markdown
Collaborator Author

Before this change, the global KB rejected every KernelForge Experience with HTTP 400 a complete Experience requires raw_logs.

Every Experience now has a raw_logs field that is intentionally empty for now: one 0-byte raw-logs-not-collected.log, and no run logs. export-runs writes that file beside its document, so a document exported earlier has to be exported again before it is collected. The schema_ref is unchanged: schema:sha256:090fe777e98b33e0487d53307df1bf72bafed2954a60826c3f7b84eb34387481.

The test upload of session gemm_a16w16_nt_n1024_k4096 created 5 Experiences, skipped 14 (lanes with no lesson), and reported 0 errors. Commit f65b87ffe.

Latest declaration: src/hyperloom_kb/declarations/kernelforge-run-v1.yaml
# KernelForge forge-loop runs: one Experience per candidate kernel a run archived (candidates/iter_NNN), the kernel
# one iteration measured and KernelForge's decision to keep or revert it. A run yields one or more: each lane of a
# fan-out round is measured as its own candidate, built from its own plan, so each lane is its own Experience.
# rationale.reasoning is the whole lesson KernelForge writes for the iteration, lessons/iter_NNN.md: the implementer's
# record of what it explored, including HELD-FIXED, NEGATIVES, and DISPROOF, with the loop's scope and outcome lines
# after the verdict. When that file is absent, a single session's reasoning is the first three sentences of the
# rationale its meta.json records. A lane or a merge with neither has none. A lesson longer than the 32 KB a text
# field holds makes collect skip the candidate. Reasoning is required: a candidate whose logs hold none, such as a
# merge of two earlier candidates, which no agent made, becomes no Experience.
# provenance.extra holds the candidate's metadata, which never reaches a prompt: meta, the whole meta.json
# KernelForge archived for the candidate, verbatim, among it the commit it kept the candidate as, the mean case
# speedup it decided on, the per-case times, every benchmark measurement, and a single session's turns and end reason;
# kb_record, the record KernelForge's own experience KB filed a kept candidate under; candidate_origin, session or
# lane; analysis_generation, the Analysis generation reflection.analysis_report comes from, as
# analysis/<commit>/generation-NNN under forge_experiments; and analysis_report_omitted, why a report too long for a
# text field is not held.
# Every Experience has a raw_logs field, and it is intentionally empty for now: it links one 0-byte file,
# raw-logs-not-collected.log, which kernelforge export-runs writes beside its document. The global KB refuses a
# complete Experience without raw logs, and KernelForge's run logs are not collected yet. raw_logs is a top-level
# Experience field, not a field of a category, so it is not declared here and takes no part in the schema_ref. Beyond
# it an Experience carries none of the profiler's output: the only run files it links are the plan, the measured
# kernel, and the archived diff of every file the commit touched (candidates/iter_NNN/change.diff). The diff and the
# kernel file are different records: the diff has every file the commit touched and not the kernel's unchanged lines,
# and the kernel file has none of the other files. The only Analysis output an Experience holds is the report its plan
# was built on, as text. The lesson is rationale.reasoning, not a second copy under reflection.
# The objective and every identity field always form the repeat group, with the schema: Hyperloom groups Experiences
# that way by itself (hyperloom_kb/query_view.py repeat_group_key) and accepts group: true only on baseline and change
# fields, so none is declared here.
schema_version: 2

objectives:
  - id: kernel_time_ms@v1
    description: >-
      Minimize the wall time the kernel takes on the task's benchmark, in milliseconds: the time KernelForge
      records for it (wall_ms). That time is the median of three run aggregates. KernelForge runs the task's
      benchmark driver three independent times. One run's time is the median of the wall_ms samples that run
      printed. If the driver prints one median_ms or mean_ms line instead, that printed number is the run's time.
      Grouped GEMM's mean_ms is the arithmetic mean of that run's per-case times, so each of its runs contributes
      a mean, and the recorded time is the median of those three means. KernelForge does not decide on this time:
      it keeps a candidate only when the candidate's mean case speedup, the equal-weight mean over the benchmark
      cases of the original kernel's time on a case divided by the candidate's, clears a bar KernelForge sets from
      the best result so far and the spread of those runs. A KEEP can still be slower on this time, because
      KernelForge keeps on mean case speedup rather than on it.
    unit: ms
    direction: lower_is_better

identity:
  - {name: operator, description: "Operator the kernel implements: the operator_name the run's campaign_config.json records, e.g. _softmax_kernel, gemm_a8w8_blockscale_bpreshuffle."}
  - {name: kernel, description: "Kernel the run optimizes: the logical operator KernelForge files its KB records under, the operator's name normalized to lower snake case without a trailing _kernel, e.g. softmax, gemm_a16w16_nt_n1024_k4096."}
  - {name: framework, description: "Framework that owns the source operator, e.g. aiter. Absent when the run records it as unknown."}
  - {name: backend, description: "Kernel language of the candidate, e.g. triton, flydsl, hip."}
  - {name: backend_version, description: "Release of the candidate's kernel language in the run's runtime, e.g. 0.3.2. forge-loop does not record it yet, so Experiences from its runs leave it absent.", kind: version}
  - {name: gpu, description: "GPU model the candidate was measured on, which also fixes its architecture target, e.g. mi355x (gfx950), mi300x (gfx942)."}
  - {name: dtype, description: "Data type of the operator's first input in the benchmark cases, e.g. bfloat16, float8_e4m3fn. forge-loop does not record it yet, so Experiences from its runs leave it absent."}
  - name: driver_sha256
    description: >-
      SHA-256 of the benchmark driver file's bytes, as the run's campaign_config.json records it after preparation.
      The hash is here so two runs of one operator on different benchmark drivers do not share a repeat group and
      have their times pooled. The same driver bytes group together. A preparation rewrite that changes the driver
      bytes splits them, because that rewrite is not deterministic; an unchanged driver does not get a new hash.
      Absent when the run recorded none.

baseline:
  - name: best_wall_ms_before
    description: >-
      The recorded wall time of the best kernel KernelForge had kept when the iteration began, in milliseconds, as
      the candidate's meta.json records it (best_wall_ms_before): the same median of three run aggregates as
      candidate_wall_ms, for the kernel the candidate was measured against. Until the first KEEP, it is
      KernelForge's own measurement of the kernel the forge-loop started from. KernelForge replaces it only when it
      keeps a candidate, with that candidate's time; since it decides the KEEP on mean case speedup rather than on
      this time, the value can occasionally rise.
    kind: number
    role: measurement
  - name: source_ms
    description: >-
      How long the original kernel took before any change, in milliseconds, the same median of three run aggregates:
      the pristine baseline the run's run_state.json records, which for a run given --baseline-json is that file's time rather
      than the time of the kernel the search started from. A run state that records none leaves the baseline time
      the candidate's meta.json archives (baseline_wall_ms).
    kind: number

# Hyperloom adds reasoning, alternatives, and preconditions to every declaration. Writing them here fails with
# `declaration.rationale redeclares a default field`. For this declaration rationale.reasoning is the whole lesson.
rationale:
  - name: plan
    description: >-
      What the plan the candidate was built from sets out to do: the whole Summary line the plan opens with, the
      planner's statement of the problem it addresses and of its goal. For a lane, the plan is the lane's own; for a
      single session, the round's optimization plan, read from its original draft when KernelForge could not revise
      the draft and put the critic's review in front of it (kernelforge/orchestrator/orchestration.py:1711 at
      1c14e79e1); for a merge, the stacked plans as KernelForge records them. A plan without a Summary line, as every
      plan written before KernelForge asked for one is, gives its first three sentences instead: lines a planner wrote
      before the plan's first heading are skipped, as are headings, code blocks, and tables.
    kind: text
  - name: plan_file
    description: >-
      The whole plan the candidate was built from, as KernelForge archived it in the planning round's
      orchestration/iter_NNN/: optimization_plan.md for a single session or a round's first lane, lane_NNN.md for
      the round's other lanes. The Experience links to the file by its name, size, and SHA-256.
    kind: file

change:
  - name: source_code
    description: >-
      The kernel file that was measured, as KernelForge archived it in candidates/iter_NNN/. The Experience links to
      the file by its name, size, and SHA-256, and the knowledge base stores the file once. This is the anchor kernel
      only. A wrapper or config edit is in change_diff_file, and the unchanged lines of this file are not in that diff.
    kind: file
  - name: change_diff_file
    description: >-
      The unified diff of every file the commit touched, as KernelForge archived it beside the kernel in
      candidates/iter_NNN/change.diff. The Experience links to the file by its name, size, and SHA-256. The name says
      it is the diff file, not another copy of the kernel: the diff does not contain the kernel's unchanged lines,
      and the kernel file does not contain the other files the commit touched. Optional: absent when the candidate
      archived no diff.
    kind: file
    required: false

outcome:
  - name: decision
    description: KernelForge's decision label for an iteration, as its forge-loop runner assigns them.
    role: decision
    values:
      - KEEP
      - REVERT_PERF
      - REVERT_VALIDATION
      - REVERT_VALIDATION_TIMEOUT
      - REVERT_VALIDATION_ERROR
      - BUILD_FAILED
      - CRASH
      - REVERT_INTEGRITY
      - REVERT_CONTENDED
      - NO_CHANGES
      - API_ERROR
      - AGENT_ERROR
      - ORCHESTRATION_ERROR
  - name: candidate_wall_ms
    description: >-
      The candidate's recorded wall time in milliseconds, as its meta.json records it (wall_ms): the median of three
      run aggregates. KernelForge runs the task's benchmark driver three independent times. One run's time is the
      median of the wall_ms samples that run printed, or the single median_ms or mean_ms summary it prints, kept as
      printed. When the driver prints mean_ms, as the grouped-GEMM driver does for the arithmetic mean of its
      per-case timings, this is the median of those three means. It is reported, not decided on: KernelForge kept or
      reverted the candidate on its mean case speedup (provenance.extra.meta.mean_case_speedup), so a kept candidate
      can be slower than best_wall_ms_before.
    kind: number
    role: measurement

reflection:
  - name: analysis_report
    description: >-
      The whole report.md of the Analysis generation the candidate's plan was built on: KernelForge's cross-case
      summary of one kernel, which its Analysis agent publishes for the commit it analyzed as one immutable
      generation, forge_experiments/analysis/<commit>/generation-NNN/. Analysis runs on the baseline, and again after
      a KEEP once the canonical mean case speedup has improved 5% on the analyzed kernel's, or just before a
      Supervisor intervention; on a campaign longer than two hours it profiles the kernel with rocprof-compute,
      through rocpc_profile.py, and a shorter campaign's report is written from the source alone. The generation is
      the one the planning round's orchestration/iter_NNN/context.json names as its analysis_bundle evidence: until a
      refresh is published, rounds plan on the last report that was, so candidates on both sides of a KEEP can carry
      the same report. Optional: absent when the round named no published generation, as for a merge, which no round
      planned, and when report.md is over the 32 KB a text field holds, which provenance.extra.analysis_report_omitted
      then says. The profiler's raw output, such as pmc_perf.csv, the rocprofv3 counter CSVs, and the profile/ and
      .rocprofv3/ directories, is never stored: it is large and tied to one binary.
    kind: text
    required: false
Example Experience, as the global KB stores it

The smallest of the five records from GET /v1/export for this schema (exp-294e0ff607015d5f9c555a2adcd54486).

{
  "baseline": {
    "best_wall_ms_before": 0.0193,
    "source_ms": 0.0223034984446
  },
  "change": {
    "change_diff_file": {
      "bytes": 1804,
      "name": "change.diff",
      "sha256": "2878c3c6a38fd3d496cb16859da174218b2ebcf2256576dc9e982a7a9bbcaf7c"
    },
    "source_code": {
      "bytes": 58301,
      "name": "kernel.py",
      "sha256": "c6bd04c1f9e5fed38b3faf602f55903240ba576320b420f069aab6d868bf8963"
    }
  },
  "completed_at": "2026-10-09T22:42:54Z",
  "created_at": "2026-10-09T22:04:11Z",
  "id": "exp-294e0ff607015d5f9c555a2adcd54486",
  "identity": {
    "backend": "flydsl",
    "driver_sha256": "5c5895c5c00265e98db657d8fd5e30060c4c65574f4bfec74c9b4cf1d1fed7a6",
    "gpu": "mi355x",
    "kernel": "gemm",
    "operator": "gemm_kernel"
  },
  "kind": "experience",
  "notes": {},
  "objective": "kernel_time_ms@v1",
  "outcome": {
    "candidate_wall_ms": 0.0198,
    "decision": "REVERT_PERF"
  },
  "parent_id": "",
  "provenance": {
    "extra": {
      "analysis_generation": "analysis/0ea69bb54ed1ff0e011e7092e0dca2d95c3ea187/generation-002",
      "candidate_origin": "session",
      "meta": {
        "baseline_wall_ms": 0.0469,
        "bench": {
          "case_bandwidth": {},
          "case_coverage_complete": true,
          "case_times": {
            "m_1": 0.0142897400539,
            "m_1024": 0.0247110600956,
            "m_128": 0.0157485499885,
            "m_16": 0.0146077300701,
            "m_2": 0.014520159997,
            "m_2048": 0.0311434799992,
            "m_256": 0.017194580026,
            "m_32": 0.0153865499888,
            "m_4": 0.0147289700247,
            "m_4096": 0.0420040100068,
            "m_512": 0.0208897599019,
            "m_64": 0.0148609500192,
            "m_8": 0.0147709200066
          },
          "mean_case_speedup": 1.1034889403370334,
          "measurement_count": 3,
          "measurement_mean_case_speedups": [
            1.1222475617175465,
            1.104227335364361,
            1.0839919239291924
          ],
          "measurements": [
            {
              "case_bandwidth": {},
              "case_times": {
                "m_1": 0.0142161299847,
                "m_1024": 0.0247810599394,
                "m_128": 0.0156849799398,
                "m_16": 0.0144397600647,
                "m_2": 0.014520159997,
                "m_2048": 0.0312618997693,
                "m_256": 0.017194580026,
                "m_32": 0.014687730046,
                "m_4": 0.0145153300837,
                "m_4096": 0.0419808397815,
                "m_512": 0.0206869698875,
                "m_64": 0.0145141400024,
                "m_8": 0.0146613600664
              },
              "median_ms": 0.0195,
              "message": "BENCH: mean=0.0195 ms",
              "stat": "mean",
              "success": true,
              "unscored_cases": []
            },
            {
              "case_bandwidth": {},
              "case_times": {
                "m_1": 0.0142897400539,
                "m_1024": 0.0247081999108,
                "m_128": 0.0159305499773,
                "m_16": 0.0146077300701,
                "m_2": 0.0141993300617,
                "m_2048": 0.0310894998349,
                "m_256": 0.0171237401105,
                "m_32": 0.0173997800238,
                "m_4": 0.0147289700247,
                "m_4096": 0.042155209966,
                "m_512": 0.0209653898701,
                "m_64": 0.0148609500192,
                "m_8": 0.0147709200066
              },
              "median_ms": 0.0198,
              "message": "BENCH: mean=0.0198 ms",
              "stat": "mean",
              "success": true,
              "unscored_cases": []
            },
            {
              "case_bandwidth": {},
              "case_times": {
                "m_1": 0.014864500016,
                "m_1024": 0.0247110600956,
                "m_128": 0.0157485499885,
                "m_16": 0.0158389699645,
                "m_2": 0.0154941399116,
                "m_2048": 0.0311434799992,
                "m_256": 0.0172557501309,
                "m_32": 0.0153865499888,
                "m_4": 0.0156249500159,
                "m_4096": 0.0420040100068,
                "m_512": 0.0208897599019,
                "m_64": 0.0156089400128,
                "m_8": 0.0157285399642
              },
              "median_ms": 0.02,
              "message": "BENCH: mean=0.0200 ms",
              "stat": "mean",
              "success": true,
              "unscored_cases": []
            }
          ],
          "median_ms": 0.0198,
          "message": "BENCH: 3 independent measurements, per-case median",
          "success": true,
          "unscored_cases": []
        },
        "best_mean_case_speedup_before": 1.1293627466034297,
        "best_wall_ms_before": 0.0193,
        "commit_hash": "",
        "complete": true,
        "decision": "REVERT_PERF",
        "delta_vs_best_pct": -2.291,
        "files": {
          "diff": "change.diff",
          "kernel": "kernel.py",
          "profile": null,
          "validation": "validation.txt"
        },
        "iteration": 13,
        "kept": false,
        "kernel_file": "kernel.py",
        "mean_case_speedup": 1.1034889403370334,
        "plan": "use BK 128 with the PIPE 3 spread-load schedule for M=1024",
        "pmc_diagnosis": "",
        "profile": {},
        "rationale": "[gate edits=9 pass=False end=candidate_submitted turns=48 mean_case_speedup=1.121535x raw_mean=0.0195ms] I applied iteration 12's m_1024 row (BK 128, PIPE 3) as the base, as the plan asks. Next I'm bu",
        "session_end_reason": "candidate_submitted",
        "shape": {},
        "snr_db": null,
        "timestamp": "2026-10-09 22:44:19",
        "turns": 48,
        "validation_passed": true,
        "vgpr": null,
        "wall_ms": 0.0198
      }
    },
    "model": "",
    "producer": "kernelforge",
    "producer_version": "10",
    "prompt_version": "",
    "snapshot_version": "",
    "source_ref": "engine/iteration-13"
  },
  "rationale": {
    "plan": "The large-M cases (m_2048 and m_4096, possibly m_1024) are limited by how many LDS fragment reads (`ds_read` bytes) they need per FLOP and by how few waves are available to hide latency. A wave's `ds_read` bytes depend only on its wave tile, so a bigger wave tile is the lever. Two ways to get one, which together make up the joint step:",
    "plan_file": {
      "bytes": 19066,
      "name": "optimization_plan.md",
      "sha256": "106884b5b3be172d7c5fae43b4fa793902b5df0df58b51d76d4cd809462cff5a"
    },
    "reasoning": "(no agent summary) session hit 10 gate rejection(s): The kernel is CORRECT but NOT faster than the current best, so it is not good en\n\nNo summarizer session was available for this iteration, so this record is machine-written from what the loop observed. It has no account of directions the agent tried and abandoned.\nSummary unavailable: WorkspaceSafetyError: the read-only session changed the workspace; restored the pre-run Git-visible state\nImplementer session ended: candidate_submitted\nImplementer turns: 48\nFinal plan: use BK 128 with the PIPE 3 spread-load schedule for M=1024\n\nNet change:\n  files: .forge_rewrite/20261009-152555-4dba86a3/kernel.py | changed\n\nIn-session gate rejections (each one the agent hit and retried):\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n- The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish.\n\nRecent provider progress (machine-captured):\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.106209x; requir\n- say: The gate rejected the unchanged file a seventh time: it scored 1.106 against a required 1.166. The session is past its deadline, so I'm not making another chang\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.127093x; requir\n- say: The gate rejected the unchanged file an eighth time: it scored 1.127 against a required 1.140. The session is past its deadline, so I'm ending without another c\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.123532x; requir\n- say: The gate rejected the unchanged file a ninth time: it scored 1.124 against a required 1.134. The session is past its deadline, so I'm handing the file off as it\n- say: Stop hook feedback: The kernel is CORRECT but NOT faster than the current best, so it is not good enough to finish. Measured mean case speedup=1.121535x; requir\n- end: subtype=success\n\nSCOPE: measured on m_1, m_1024, m_128, m_16, m_2, m_2048, m_256, m_32, m_4, m_4096, m_512, m_64, m_8 | held fixed (not recorded) | carries a measured negative | whether a feasibility claim was disproved was not recorded\n\nOUTCOME: REVERT_PERF | mean case speedup 1.103489x vs best 1.129363x | wall 0.0198 ms vs best 0.0193 ms | session ended: candidate_submitted"
  },
  "raw_logs": [
    {
      "bytes": 0,
      "name": "raw-logs-not-collected.log",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    }
  ],
  "reflection": {
    "analysis_report": "# Analysis report \u2014 `gemm_a16w16_nt_n1024_k4096`, canonical 0ea69bb5 (incremental from 3f155afd)\n\n**Mode:** PROFILED, incremental. **GPU:** MI355X (gfx950). PMC and traces ran on `ROCR_VISIBLE_DEVICES=3`; graph A/B, sweeps and ISA ran on device 4.\n\n**Measured in this session (0ea69bb5):**\n- rocprofv3 kernel traces for **all 13 cases**.\n- Own 9-group PMC (63 passes, all exit 0) on the **7 representatives**: m_1, m_8, m_16, m_64, m_512, m_2048, m_4096.\n- ISA plus a correctness check for the 7 changed binaries.\n- Hot CUDA-graph A/B of every M against the parent 3f155afd source.\n- An empty-kernel graph floor.\n- Two forced-config exploration sweeps.\n\n**Inherited (labelled, not re-measured):**\n- m_128 and m_256 PMC/ISA from 3f155afd: `cases/*/profile/inherited_3f155afd/`.\n- m_1024 partial PMC from 049413f3 via 3f155afd.\n- 049413f3 PIPE-2 / register-path PMC copied as `profile/parent_baseline_049413f3/` for before/after comparison only.\n\n**Session attempt 2 (resume):**\n- The canonical source in the worktree is byte-identical to 0ea69bb (`git diff 0ea69bb -- <source>` is empty), so every attempt-1 measurement above still describes this commit.\n- No GPU work was repeated.\n- `incremental_diff.patch` was restored from `analysis/deltas/3f155afd_to_0ea69bb.patch`, which is identical to `git diff 3f155af 0ea69bb` and covers both KEEP commits (8a2edc8, 0ea69bb).\n- The m_4096 direction text was aligned with \u00a76.\n\n## 0. What changed across the KEEP sequence (2 commits)\n\n| commit | mechanism | cases |\n|---|---|---|\n| **8a2edc8** (iter-6) | **Small M \u2192 `gemm_lds_splitk_kernel`.** BN=16 tiles with 4 (8 at M=8) K-split waves, each with a wave-private 4-stage LDS ring. **EDGE** handles M < 16: the A/C descriptors are sized to M, A loads past M are skipped, and C stores past M are dropped. **PARTIAL** store: VEC \u2265 2, only BM\u00b7BN/VEC threads store. | m_1, 2, 4, 8, 16, 32, 64 (previously `gemm_splitk_kernel` / `gemm_kernel`) |\n| **0ea69bb** (iter-9) | **`gemm_lds_kernel` PIPE 3.** The next tile's LDS-DMA loads are spread evenly over the MFMA slots, `N_PRE` before the mid-tile barrier and the rest after. Optional `s_setprio` (PRIO) and `sched_barrier` fences (PIN). | m_512 (N_PRE 4, PIN 1), m_2048 (N_PRE 2, PRIO 1), m_4096 (N_PRE 2, PIN 1) |\n| \u2014 | Binaries unchanged: m_128 and m_256 (EDGE/PARTIAL are compiled out), m_1024 (still PIPE 2) | m_128, m_256, m_1024 |\n\n**Hot-graph A/B, canonical vs parent 3f155afd, same GPU** (`tools/bench/graph_ab.txt`):\n\n| case | per-call parent \u2192 canon (\u00b5s) | one-call parent \u2192 canon (\u00b5s) | harness 3f155afd bundle \u2192 now (\u00b5s) |\n|---|---|---|---|\n| m_1 | 4.86 \u2192 **2.98** | 15.68 \u2192 14.68 (13.96 in a 2nd sweep) | 15.24 \u2192 13.93 |\n| m_2 | 5.00 \u2192 **3.03** | 14.64 \u2192 15.04 (one-call jitter \u00b10.5) | 15.36 \u2192 13.99 |\n| m_4 | 5.06 \u2192 **3.03** | 14.80 \u2192 13.96 | 15.55 \u2192 14.14 |\n| m_8 | 5.31 \u2192 **3.11** | 15.12 \u2192 14.04 | 15.62 \u2192 14.30 |\n| m_16 | 5.63 \u2192 **3.49** | 15.40 \u2192 13.92 | 15.81 \u2192 14.49 |\n| m_32 | 6.65 \u2192 **3.55** | 16.80 \u2192 13.96 | 16.98 \u2192 14.40 |\n| m_64 | 8.59 \u2192 **3.88** | 18.76 \u2192 14.08 | 18.88 \u2192 14.43 |\n| m_128 / m_256 | 5.07 / 6.39 \u2192 5.02 / 6.25 (unchanged) | = | 15.84 / 17.11 \u2192 15.72 / 17.23 |\n| m_512 | 10.55 \u2192 **9.79 (\u22127.2%)** | 21.32 \u2192 20.56 | 21.4 \u2192 20.88 |\n| m_1024 | 15.45 \u2192 15.46 (unchanged) | = | 25.7 \u2192 25.48 |\n| m_2048 | 22.91 \u2192 **21.47 (\u22126.3%)** | 32.16 \u2192 31.56 | 32.3 \u2192 31.10 |\n| m_4096 | 34.99 \u2192 **33.03 (\u22125.6%)** | 44.76 \u2192 43.14 | 44.3 \u2192 41.94 |\n\n**Harness sum:** 269.9 \u2192 **252.0 \u00b5s (\u22126.6%)**. All changed binaries pass the fp32-reference check with the same max_err as the parent (`tools/isa/m_*/stdout.log`).\n\n## 1. Bottom line\n\n1. **Small M (\u2264 64, 7 of 13 cases) is now at the launch floor.**\n   - A trivial elementwise kernel captured the same way costs **13.80 \u00b5s** one-call and 1.88 \u00b5s per call (`tools/bench/graph_floor.txt`).\n   - Canonical M \u2264 64 runs 13.9\u201314.1 \u00b5s one-call and 13.93\u201314.49 \u00b5s harness. The remaining headroom is **\u2264 0.1\u20130.7 \u00b5s per case (\u2264 1\u20135%)**.\n   - The PMC shows a latency-dominated short stream:\n     - MFMA \u2264 2% busy; TA \u2264 22% per active CU.\n     - 45\u201358% of wave cycles are spent waiting; L1\u2192L2 latency is 480\u2013520 cycles.\n     - B is re-fetched from fabric on every launch (fabric \u00f7 unique 1.0\u20131.1).\n     - At M \u2264 16 only 64 of 256 CUs have work.\n   - More waves per WG were **measured slower** (SPLIT_K 8/16, BK 32). Do not spend more turns here.\n2. **Every LDS-staged kernel is still on the feed law: WG-level L2\u2192LDS feed F \u2248 16\u201318 TB/s.**\n\n   | case | F (TB/s, trace) | FLOP/B | TFLOP/s |\n   |---|---|---|---|\n   | m_512 | 17.4 | 21.3 | 372 |\n   | m_2048 | 18.3 | 42.7 | 781 |\n   | m_4096 | 16.2 | 64 | 1036 |\n\n   - PIPE 3 won by **overlap, not bytes**. L1\u2192L2 \u00f7 WG request stays 1.000 and fabric \u00f7 unique is unchanged (2.0 / 2.29 / 2.46).\n   - At m_4096 vs the 049413f3 PIPE-2 PMC: MFMA busy 40.1 \u2192 43.0%, TA busy 48 \u2192 54%. At m_2048: MFMA 26.9 \u2192 28.4%.\n3. **The large-M remaining gap is TA/MFMA under-utilization.**\n   - At m_4096, TA is 54% busy at ~20 TA cycles per 1 KiB LDS-DMA, i.e. ~51 B/clk/CU when busy. The CU could feed ~1.8\u00d7 more if TA stayed busy.\n   - MFMA is 43%, and waves spend 42% in `wait_inst` and 14.5% in LDS-wait.\n   - The kernel is limited by load-issue / barrier coupling, not by a saturated unit.\n4. **The equal-weight objective is now dominated by m_128 \u2026 m_4096.**\n   - m_4096 alone is 17% of the harness sum.\n   - The best remaining levers: raise F at M \u2265 1024 (D-L4\u2032), and bigger tiles plus inter-WG split-K at m_128 / m_256 (D-K5).\n\n## 2. Grouping decision\n\n| group | cases | representative(s) | transferable evidence and rationale |\n|---|---|---|---|\n| **SK-small** (8a2edc8) | m_1, m_2, m_4, m_8, m_16, m_32, m_64 | **m_1** (EDGE, BK 128), **m_8** (EDGE, SPLIT_K 8, unrolled), **m_16** (full tile, 64 WGs), **m_64** (256 WGs): all with own PMC | One template, but three distinct binaries (EDGE/BK128, EDGE/SK8, full/BK64) and two grid regimes (64 vs 256 WGs), so each got its own representative. m_2 and m_4 reuse m_1: identical config row and EDGE path, only constants differ, and own traces 4.38 / 4.32 vs 4.28 \u00b5s. m_32 reuses m_16: identical row, 128 WGs, trace 4.40 vs 4.28. All four representatives give the same verdict (latency-bound, at the floor), which validates the reuse. |\n| **L3** (0ea69bb, PIPE 3) | m_512, m_2048, m_4096 | each profiled independently | Three different PIPE-3 tunings (N_PRE / PRIO / PIN) and tiles; no reuse needed. |\n| **K** (unchanged) | m_128, m_256 | inherited 3f155afd own PMC | Binary unchanged. Fresh trace and A/B match the parent (5.02 vs 5.07, 6.25 vs 6.39 \u00b5s). |\n| **L2** (unchanged) | m_1024 | inherited 049413f3 partial PMC | Config is PIPE 2, byte-identical. A/B 15.46 vs 15.45 \u00b5s. |\n\nNo pooled multi-case counters are used. Exploration timings on forced configs live in `tools/bench/graph_explore_*.txt` and are never presented as a case profile.\n\n## 3. Key measurements (this session; raw under `cases/*/profile/pmc_g*/`, normalized in `profile/normalized_metrics.json`)\n\nThe GRBM-normalized busy percentages are lower bounds. The implied clock exceeds 2.4 GHz on short kernels because GRBM includes dispatch overhead.\n\n| metric | m_1 | m_8 | m_16 | m_64 | m_512 | m_2048 | m_4096 |\n|---|---|---|---|---|---|---|---|\n| trace (\u00b5s) / WGs \u00d7 waves | 4.28 / 64\u00d74 | 3.88 / 64\u00d78 | 4.28 / 64\u00d74 | 4.84 / 256\u00d74 | 11.56 / 256\u00d74 | 22.0 / 256\u00d78 | 33.16 / 256\u00d78 |\n| wait-any / wait-inst / active % | 45 / 23 / 31 | 56 / 32 / 12 | 58 / 11 / 31 | 56 / 14 / 32 | 32 / 25 / 44 | 38 / 28 / 34 | 26 / 42 / 32 |\n| MFMA busy % | 0.5 | 0.5 | 0.5 | 1.8 | 10.2 | 28.4 | 43.0 |\n| LDS-wait % | 0.7 | 1.0 | 0.4 | 0.4 | 3.5 | 8.7 | 14.5 |\n| WG feed (TB/s) / FLOP/B | 2.1 (L1\u2192L2) / 0.8 | 3.2 / 5.3 | 3.9 / 8 | 13.9 / 8 | 17.4 / 21.3 | 18.3 / 42.7 | 16.2 / 64 |\n| L2 hit % / fabric \u00f7 unique | 7 / 1.01 | 30 / 1.06 | 44 / 1.11 | 81 / 1.40 | 83 / 2.46 | 83 / 2.29 | 80 / 2.00 |\n| L1\u2192L2 latency (cycles) | 522 | 517 | 503 | 482 | 426 | 435 | 382 |\n| TA busy % per active CU / cycles per KiB | 14 / 21.7 | 17 / 24.0 | 22 / 22.8 | 21 / 22.7 | 39 / 20.6 | 53 / 19.7 | 54 / 20.1 |\n\n## 4. Interpretation\n\n**Small M \u2014 why ~3 \u00b5s per call, and why it cannot shrink much.**\n- Each of the 64 active CUs streams 128\u2013256 KiB through a 4-deep per-wave ring.\n- The stream is short: 8\u201316 K-tiles per wave.\n- Prologue fill, epilogue fold through LDS, and dispatch dominate. MFMA is ~0.5%.\n- Doubling the bytes in flight per CU (SPLIT_K 8) helps only at M=8 (EDGE, half the A loads). At M=16/32/64 it costs +0.1 \u00b5s, and 16 waves cost +0.4\u20130.7 \u00b5s.\n- m_64 fills all 256 CUs with 4\u00d7 the L2 requests yet costs only +0.4 \u00b5s per call over m_16. Time is dominated by fixed latency, not bytes.\n- In the harness the kernel is hidden behind the ~10.5\u201312 \u00b5s launch floor: canonical one-call \u2212 empty one-call is \u2248 0.1\u20130.3 \u00b5s.\n\n**Large M \u2014 PIPE 3 confirms the feed ceiling is an overlap ceiling.**\n- Spreading loads across MFMA slots raised TA busy (48 \u2192 54%) and MFMA busy (40 \u2192 43%) together, and F rose slightly at m_2048 (17.8 \u2192 18.3 TB/s).\n- Neither unit is near saturation. TA at ~20 cycles per KiB gives ~51 B/clk/CU when busy (\u2248 31 TB/s aggregate at 2.4 GHz), so F \u2248 16\u201318 TB/s is about 55% of the LDS-DMA issue ceiling.\n- The limiter is how often the TA is idle. Waves are in `wait_inst` 28\u201342% of cycles (vmcnt/lgkmcnt), lock-stepped by the per-tile `s_barrier`.\n- KEEP-comment data: deeper rings (STAGES 5/6) and BK 128 were slower, and inter-WG split-K lost at 512 / 1024 / 2048.\n\n**m_128 / m_256.** Unchanged binaries on the same ~16.6 TB/s feed law (inherited 3f155afd PMC). The new sweep (`graph_explore_midM.txt`) shows that smaller tiles with more WGs lose 1.2\u20135 \u00b5s. Only bigger tiles (higher FLOP/B) can help.\n\n## 5. Remaining headroom (harness \u2248 hot one-call graph; floor 13.80 \u00b5s)\n\n| case(s) | harness now (\u00b5s) | lever | kernel per-call now \u2192 target (\u00b5s) | harness after (\u00b5s) | gain | evidence |\n|---|---|---|---|---|---|---|\n| m_1 \u2026 m_64 | 13.93\u201314.49 | none worth a turn | 3.0\u20133.9 \u2192 \u2265 1.9 floor | \u2265 13.8 | \u2264 1\u20135% | measured floor and negative sweeps |\n| m_4096 | 41.94 | D-L4\u2032: raise F via load/MFMA decoupling | 33.0 \u2192 ~26\u201328 | ~35\u201337 | \u221212 to \u221216% | TA 54%, MFMA 43%, wait-inst 42% (model) |\n| m_2048 | 31.10 | D-L4\u2032 | 21.5 \u2192 ~18 | ~27.5 | \u221210% | TA 53%, MFMA 28% (model) |\n| m_1024 | 25.48 | D-L4\u2032; PIPE 3 measured no gain (KEEP comment) | 15.5 \u2192 ~13 | ~23 | \u22128% | inherited PMC (model) |\n| m_256 / m_128 | 17.23 / 15.72 | D-K5: bigger tile + inter-WG split-K | 6.3 \u2192 ~4.5\u20135 / 5.0 \u2192 ~4 | ~15.5\u201316 / ~14.8 | \u22127 to \u221210% / \u22126% | feed-law model; smaller tiles measured to lose |\n| m_512 | 20.88 | \u2014 | 9.8 \u2192 ~9 | ~20 | \u2264 \u22124% | feed-bound; split-K lost (KEEP comment) |\n\n## 6. Prioritized directions\n\n**1. D-L4\u2032 \u2014 decouple LDS-DMA issue from the MFMA/barrier cadence in `gemm_lds_kernel`** (m_4096, m_2048, m_1024).\n- Goal: keep the TA continuously busy.\n- Options:\n  - (a) Producer-wave specialization: 1\u20132 waves of the WG issue all `buffer_load \u2026 lds` for upcoming stages, while the other waves only `ds_read` + MFMA. Sync is per-stage via an LDS flag or a split barrier, instead of every wave issuing loads and then waiting at a full `s_barrier`.\n  - (b) Per-wave-private ring (the `gemm_lds_splitk_kernel` pattern) for the B operand only, where each wave's B slice is private (WARPS_N split).\n- Success criterion: TA busy > 70%, F > 21 TB/s.\n- Caveat (from 3f155afd): barrier removal alone did not raise F in the split-K kernel. The win must come from keeping **more** loads in flight per CU, without deeper LDS stages (STAGES 5/6 were slower).\n\n**2. D-K5 \u2014 bigger WG tiles at m_128 / m_256, kept at 256 WGs via inter-WG split-K** inside `gemm_lds_splitk_kernel`.\n- m_128: 32\u00d732 with 2 K-groups across WGs (FLOP/B 10.7 \u2192 16).\n- m_256: 32\u00d764 or 64\u00d764 with 2\u20134 K-groups (FLOP/B 16 \u2192 21\u201332).\n- Reuse `gemm_splitk_kernel`'s last-arriver workspace. Its tail (~0.5\u20131 \u00b5s) must stay below the feed savings (~1\u20132 \u00b5s).\n- This session measured the opposite direction (smaller tiles) as losing 1.2\u20135 \u00b5s, which supports the feed-law premise.\n\n**3. Reduce LDS fragment traffic per FLOP at m_4096** (LDS-wait 14.5%, rising with M).\n- Wave tile 64\u00d764 (2\u00d72 warps on 128\u00d7128) instead of 64\u00d732 (2\u00d74) cuts `ds_read` bytes per FLOP by 33%.\n- Risk: 4 waves per CU, 1 per SIMD, so latency hiding drops.\n- Combine with 1 (a producer wave would restore wave count).\n- Inherited D-L5 (MFMA 32\u00d732\u00d716) mainly cuts instruction count, not LDS bytes, for the same wave tile, so expect \u2264 1\u20132 \u00b5s.\n\n**4. Small M: stop.** The harness is within 0.1\u20130.7 \u00b5s of the empty-kernel graph floor.\n- Measured negatives this session: SPLIT_K 8 at M=16/32/64, 16-wave (BK 32) rings, BK 32 at M=64.\n- Previously measured negatives (KEEP comment): SPLIT_K 2, 6\u20138 stage rings.\n\n**Not recommended (measured):**\n- 16\u00d716 / 16\u00d732 / 32\u00d716 tiles at m_128 / m_256 (this session).\n- PIPE 3 at m_1024, STAGES 5/6, BK 128 at 2048/4096, and inter-WG split-K for M \u2265 512 (KEEP comment, 0ea69bb).\n- Inherited not-recommended items from 3f155afd still stand.\n\n## 7. Tooling and provenance\n\n- **rocprof-compute:** re-attempted via the supplied `tools/rocpc_profile.py` (m_64, 150 s timeout). It exited **3** (\"installed, but its Python deps are not available\"; `tools/rocpc_preflight/preflight.log`).\n- **Fallback, recorded in `commands.jsonl`:**\n  - rocprofv3 `--kernel-trace` (200 launches, first dropped).\n  - 9 small PMC groups of 2\u20134 counters each: one group per rocprofv3 process, separate output dir per pass, 150 s timeout, no retries (`tools/run_profile.sh`, retargeted from 3f155afd).\n  - All 13 traces and 63 PMC passes exited 0.\n- **Adapter:** `tools/case_adapter.py` (copied from 3f155afd; it imports the canonical `kernel.py` read-only).\n- **Normalizer:** `tools/norm_r.py`, updated for the new config tables and EDGE byte accounting. `tools/show_norm.py` prints summaries.\n- **Members and case files:** `tools/write_members_0ea.py` writes the member / inherited metrics; `tools/write_case_md.py` generates the case analyses.\n- **A/B:** `tools/graph_ab.py` against `tools/parent_src/parent_3f155afd_src.py` (a `git show` export).\n- **Floor:** `tools/graph_floor.py`. Its stdout was transcribed to `bench/graph_floor.txt`. The read_B row uses an unoptimized torch reduction and is not a byte floor.\n- **ISA:** `FLYDSL_DUMP_IR=1 FLYDSL_DEBUG_DUMP_ASM=1` into `tools/isa/m_*`, summarized in `tools/isa/loop_summary.txt`. The summarizer was patched to report fully unrolled loops (M=8).\n- **Caveats:**\n  - Graph one-call medians jitter by \u00b10.5 \u00b5s between sweeps (m_1: 14.68 vs 13.96); per-call medians are stable to \u00b10.05 \u00b5s.\n  - Kernel-trace times exceed graph per-call by ~1\u20131.3 \u00b5s at small M.\n  - F is an empirical fit, not a hardware spec."
  },
  "rendered_refs": [],
  "run_id": "58a1e342578b4d949f55fa177fb9089c",
  "schema_ref": "schema:sha256:090fe777e98b33e0487d53307df1bf72bafed2954a60826c3f7b84eb34387481",
  "schema_version": 2,
  "seq": 13,
  "status": "complete",
  "supersedes": ""
}

@chaojhou chaojhou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #2037 -- feat(kernelforge): collect KernelForge runs into the Experience KB

What it does: Adds kernelforge export-runs, which turns archived forge-loop runs into a run document, and a kernelforge-v1 mapping plus kernelforge-run-v1 declaration so hyperloom-kb-collect publishes each candidate as an Experience. It also moves KernelForge's decision labels onto one DecisionLabel enum and fixes task preparation for a driver that git ignores.

Blocking issues: 1

  1. [free:verbatim-meta-strict-screen-skips] Agent prose and driver case ids are screened with the strict configuration rules, so ordinary candidates are skipped as "sensitive content" [verified]
    Problem: candidate_unit passes the whole meta.json into the unit's provenance (meta=meta, src/kernelforge/run_export/forge_loop.py:234), and the mapping publishes it as extra: $run.provenance (src/hyperloom_kb/mappings/kernelforge-v1.yaml:59). That puts bench.case_times keyed by driver case ids, plus the plan and rationale prose, under provenance.extra.meta. The mapping also declares plan: {text: $run.plan} (kernelforge-v1.yaml:46). _screen (src/hyperloom_kb/collect/engine.py:321) treats as free text only FREE_TEXT_FIELDS (src/hyperloom_kb/collect/sensitive.py:72: rationale.reasoning, rationale.alternatives, reflection, notes, raw_logs) and the change SUMMARY role. Everything else gets the configuration rules, which sensitive.py:4-7 says misfire on prose such as "per token: ...". I reproduced this with a dry-run collect('kernelforge-v1', ...) on the head, using the PR's own _session_unit fixture. The neutral unit was collected. Each of these variants was skipped:
    • case id tokens_128_e8 gave provenance.extra.meta.bench.case_times.tokens_128_e8: credential-shaped key
    • meta plan Summary: per token: one scale. gave provenance.extra.meta.plan: credential assignment
    • meta rationale containing "max_tokens": 4 gave credential-shaped key
    • run plan Summary: per token: one scale. gave rationale.plan: credential assignment
      Impact: A run whose driver names cases with TOKEN, SECRET or PASSWORD in them is affected (case ids are free-form, taken from the case_ms:\s*(\S+) lines the driver prints). So is a run whose planner writes "per token: ..." or a JSON snippet with a *token* key. Every such candidate is dropped at collect even though it carries a lesson. These are typical of token-indexed kernels (MoE, per-token quant), so whole runs can publish no Experiences.
      Action: Export only the meta fields the declaration needs (measurements and decision inputs) rather than meta.json verbatim, and leave the plan and rationale prose out, since rationale.plan, plan_file and reasoning already carry it. Also screen rationale.plan as free text, for example through a declaration role the way change SUMMARY is handled. Add tests in src/hyperloom_kb/tests/test_collect_kernelforge.py for a token-named case id and for a "per token:" plan.

Checked: src/kernelforge/run_export/{forge_loop.py,units.py}, src/kernelforge/loop/archive.py (_metadata), src/kernelforge/evaluation/benchmark/runner.py (case_times, failed-bench output), src/hyperloom_kb/mappings/kernelforge-v1.yaml, src/hyperloom_kb/declarations/kernelforge-run-v1.yaml, src/hyperloom_kb/collect/{engine.py (_screen),sensitive.py}, src/kernelforge/loop/{decision_labels.py,agent_relay.py,knowledge_integration.py,task_preparer.py}, PR body/comments | Ran: git archive of head to /tmp/hlx-2037-2b, then dry-run collect('kernelforge-v1', ...) on fixture variants; CI job logs (the only failure is the main-side collection ERROR in test_multi_node_kept_patch_restore.py, fixed by #2149) | Base: d0d690b | Head: 73c7742

amd-ama10002-2 and others added 15 commits October 23, 2026 17:24
….reasoning.md

An Experience built from a KernelForge run needs a short statement of the
plan a candidate was built from and the implementer's own account of its
change. The logs held neither: the nearest text was the first sentences of
a long plan and the session's opening messages, which are tool narration
("I'll start by reading the required optimization plan...").

The planner's synthesis, lane-synthesis and revision prompts now ask for a
first line, before any heading, of `Summary:` and one to three plain
sentences: the problem the plan addresses and its goal. The implementer's
system prompts ask it to keep a work record at the path its invocation
names, opening with its own `Summary:` line (the problem and the change it
makes), brought up to date before the session ends.

The runner names that path for every session that executes a plan:
<plan>.reasoning.md beside the plan in forge_experiments/orchestration/
iter_NNN/, which the workspace guard ignores, the candidate archive does
not replace, and a lane's temporary workspace copy does not hold. A lane
session gets the plan's directory as an additional directory, so its log
outlives the copy. The stale-lane-plan cleanup now matches only
lane_NNN.md: re-planning an iteration still removes the lane plans the new
round no longer has, and no longer deletes the reasoning logs lanes wrote.

kernelforge.orchestrator.summary_line owns the format: both prompt rules,
the invocation section that names the log, the parser that reads the
summary back, and where the log lives. The reasoning-log
rule is worded as an engineering record of what was examined, changed and
measured; a wording that asked for the reasoning itself and the model's
hypotheses tripped Claude-Opus-5.5's [reasoning_extraction] safeguard on
every writable implementer session.

Refactors with nothing observable: the planner system prompts move to
plan_prompts.py, the two hygiene rules and the session deadline section
become module-level names in agent.py, and the runner's signature check
for optional agent_fn arguments becomes loop.agent_extras.declared_extras.
The code-metrics baseline is tightened for the three units that shrank.

Observable effect: planner plans in forge_experiments/orchestration/
iter_NNN/ open with `Summary:` and one to three sentences, and every
implementer session that executes a plan leaves <plan>.reasoning.md beside
it, opening with its own `Summary:` line. Nothing in KernelForge parses a
plan's first line, so plans are otherwise used as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s one GateScoring

The runner handed an injected Implementer callback the pristine per-case
times and the current best mean case speedup as two optional arguments.
They travel together, into the in-session gate and into the scoring
section of its system prompt, and with the reasoning log they made
make_agent_fn's agent_fn a six-parameter function, one over the
code-metrics limit.

kernelforge.loop.scoring.GateScoring carries both as one frozen
dataclass. The runner passes it as the optional gate_scoring argument
through declared_extras, and agent_fn hands it to the gate builder and
to the scoring section. agent_fn is back to five parameters and
_build_insession_gate takes fifteen; the code-metrics baseline is
tightened for both.

Refactor with nothing observable: the gate scores against the same
values and the session is told the same scoring state. Two tests pin
the handoff: the runner hands a session its gate's scoring, and the
canonical session's gate and prompt carry what it was handed.

Co-authored-by: Cursor <cursoragent@cursor.com>
…orkspace

An implementer session now keeps its reasoning log under
forge_experiments/orchestration/iter_NNN/. In a workspace whose ignore
rules do not cover forge_experiments/, that log is a new untracked file,
so WorkspaceGuard.verify() rejected the session ("new non-ignored files
are unsupported") and rolled back its kernel edits along with the log.
The example script's .gitignore and the Hyperloom controller both hide
the folder, which is why no run under them showed it.

forge-loop now hides its own output folder the way the controller
already did: IterationLoop._run_impl writes forge_experiments/.gitignore
holding `*` before any session runs, so git never lists anything the
loop or a session writes there. The controller's private helper moves to
llm.workspace_policy.hide_forge_loop_output and both callers share it.
The guard is unchanged: a new file anywhere else in the workspace is
still rejected and rolled back.

Observable effect: every forge-loop workspace gets
forge_experiments/.gitignore (`*`), and a workspace that did not ignore
that folder no longer has an implementer session rejected, and its
kernel edits rolled back, when the session writes its reasoning log.

Co-authored-by: Cursor <cursoragent@cursor.com>
…export-runs and kernelforge-v1

A KernelForge forge-loop run archives, for every candidate kernel it
measured, the plan it was built from, the reasoning log its implementer
kept, the kernel itself, KernelForge's decision and the lesson it drew.
None of that reached the Experience KB, so a later run could not read
what an earlier one tried on the same kernel and how it went.

`kernelforge export-runs <LOG_ROOT>... -o runs.json` reads the plain
forge-loop runs under the given directories and writes one JSON
document, `{"runs": [...]}`, with one unit per archived candidate, so
each lane of a fan-out round is a unit of its own. A candidate's plan
is the whole Summary line its plan opens with, and its reasoning is the
whole reasoning log its implementer kept beside that plan or, for a
single session that kept none, the first three sentences of the
rationale its meta.json records. A run's forge-loop.log gives the
implementer's backend and model and the record KernelForge's own
experience KB filed a kept candidate under. The log is only read: a
unit names no log and no path to one. A log belongs to the run whose
workspace path ends most like the kernel path it announces; one that
fits two runs equally well stops the export. A run or candidate whose
JSON records cannot be read becomes one unit saying why.

The packaged `kernelforge-v1` mapping projects those units onto the
packaged `kernelforge-run-v1` declaration. Every Experience records the
`kernel_time_ms@v1` objective, in ms, lower is better: the median over
three independent runs of the task's benchmark driver, each run's time
being the driver's median over all its timing samples.
baseline.current_best_median_ms is the best kept kernel's time when the
iteration began (meta.json best_wall_ms_before),
outcome.candidate_median_ms is the candidate's own (wall_ms), and
baseline.source_ms is the pristine baseline run_state.json records.
KernelForge keeps a candidate on its mean case speedup, not on this
time, so the declaration says a kept candidate can occasionally be
slower in milliseconds than the best kernel before it. Identity is
operator (campaign_config.json operator_name), kernel (the name
KernelForge files its own KB records under, the operator normalized),
framework, backend, backend_version, gpu and dtype; forge-loop does not
record backend_version or dtype yet, so its Experiences leave them
absent. rationale.reasoning holds the whole reasoning log, so a log
longer than the 32 KB a text field holds makes collect skip the
candidate. The plan, the measured kernel and the lesson are the only
files an Experience links; it carries no raw_logs, and the run's
metadata goes in provenance.extra: meta, the candidate's whole
meta.json, plus llm_backend, llm_model, kb_record and candidate_origin.
A unit is skipped when its logs could not be read as a run, or when it
has no reasoning: a merge, which no agent made, or a lane whose
implementer kept no reasoning log.

Observable effect: a new `kernelforge export-runs` subcommand, and
`hyperloom-kb-collect --mapping kernelforge-v1 --document runs.json`
now collects KernelForge runs into the Experience KB. Nothing changes
for a forge-loop run itself. The KernelForge CLI reference and the
Experience collection reference document both.

Co-authored-by: Cursor <cursoragent@cursor.com>
…as built on

KernelForge's Analysis agent writes a cross-case report of the kernel a
round plans against, report.md in the immutable generation it publishes
for the analyzed commit, forge_experiments/analysis/<commit>/generation-NNN/.
On campaigns longer than two hours that report is built from a
rocprof-compute profile. It is the evidence the planner read, and none
of it reached the Experience KB.

kernelforge-run-v1 gains reflection.analysis_report, optional text: the
whole report.md of the generation the candidate's planning round named
as its analysis_bundle evidence in orchestration/iter_NNN/context.json.
A lane shares its round's report; a merge, which no round planned, has
none. The generation is found by the last three components of the path
the context records, under the run's own forge_experiments, so a run
made elsewhere still resolves; a bundle still at work under
analysis/work/ is not a published generation and gives no report.
provenance.extra.analysis_generation names the generation the report
came from. A report over the 32 KB a text field holds is left out, and
provenance.extra.analysis_report_omitted says so with its size. No raw
profiler output is declared or linked, and raw_logs stays empty.

kernelforge.run_export takes the text-field limit from
hyperloom_kb.schema.TEXT_MAX_BYTES rather than restating it.

Co-authored-by: Cursor <cursoragent@cursor.com>
The rewrite controller copies an operator's driver into a staging folder
inside the workspace that hides itself from git with a .gitignore of *
(kernel_rewrite_controller/worktree.py). When the preparation agent
repaired that driver, nothing could be staged, so the prepass commit
landed nothing, HEAD stayed at the base commit, and preparation rolled
the repaired driver back and failed with "prepared a conforming driver
but the git commit did not land". Every controller task whose driver
needed repair failed this way, which left a Hyperloom kernel phase with
no forge-loop run at all.

_commit_prepared_task now fails only when something was staged and the
commit still did not land. With nothing staged, the prepared driver
stays where it is and runs in place, since it was never part of the
pristine commit.

Observable effect: a controller task whose driver the preparation agent
repairs is prepared and runs its forge-loop, instead of failing task
preparation and rolling the repair back. A staged change whose commit
is rejected still fails and rolls back as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
wall_ms is the median of three runs of the aggregate the driver printed,
and KernelForge logs that value as its raw mean. A driver that prints
mean_ms contributes that mean, so the declaration was calling a mean a
pooled median. The objective and the baseline and outcome fields now say
mean_ms and describe that measurement.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Experience keeps the lesson as its only reasoning, splits repeats on the benchmark driver, links the commit diff, and names the recorded wall time.

Co-authored-by: Cursor <cursoragent@cursor.com>
…still exists

A lane takes its factual-record turn at the end of its round, while that copy of the workspace still exists, and lane_queue.json carries the lesson to the iteration that measures it. Later Implementer prompts read that narrative instead of the machine fallback written after the lane copy is gone.

Observable effect: a lane iteration writes lessons/iter_NNN.md from the lane's own record, and a restarted process still has that record in lane_queue.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ntries

Those fields were filled only from an Implementer log line that forge-loop no longer prints, so a run made with the current log never had them. The producing Agent profile stays in the candidate's meta.json, carried in provenance.extra.meta.

Observable effect: export-runs entries and the Experiences collected from them no longer have llm_backend or llm_model.

Co-authored-by: Cursor <cursoragent@cursor.com>
…from their writers

Add kernelforge.loop.experiments_layout, which names the paths, file names and timestamp format of a run's forge_experiments archive, and build LessonStore, HandoffStore, CandidateArchive, LoopStateStore, CampaignConfigStore and the runner's orchestration paths from it.
Add kernelforge.loop.loop_log, which renders the banner, kernel line, iteration header and experience-KB line the loop prints and compiles the patterns that read them back.
Name the iteration_started and iteration_result event types beside make_event, and the lane-copy prefix and the lane rationale, with its reader, in fanout.
Move the gate outcome tag to kernelforge.orchestrator.gate_tag and the critic fallback plan to kernelforge.orchestrator.critic_fallback, each beside the reader that strips the tag or recovers the draft.
The exporter reads the archive, the log lines, the event types, the lane rationale, the gate tag and the critic fallback through those owners instead of its own copies, and takes the diff's file name from meta.json.
The export tests build their fixtures through the same writers, with every fixture value and every asserted output unchanged.

Observable effect: none (refactor; exported units are unchanged)

Co-authored-by: Cursor <cursoragent@cursor.com>
…laration against them

Add kernelforge.loop.decision_labels, which defines DecisionLabel with the 13 labels the forge-loop records and DECISION_LABELS, the set of their values. runner.py takes every label it produces, including those in _decision_label and _LABELS_WITHOUT_A_MEASURED_NEGATIVE, from DecisionLabel instead of a string literal. test_decision_labels.py asserts that the kernelforge-run-v1 declaration's outcome.decision values equal DECISION_LABELS.

Observable effect: none (refactor and test)

Co-authored-by: Cursor <cursoragent@cursor.com>
… hand lanes the session sink directly

The lane session and the lane factory hand session_sink to the lane agent directly instead of through declared_extras, and the five lane-agent test doubles accept it.
The test that pinned that introspection and the agent_extras docstring sentence that narrated older callbacks are deleted.
LaneResult.lesson is now the lane's SummaryOutcome, which run_lanes stores exactly as the session returned it.
lane_queue.json entries write lesson_reason beside lesson, and an entry holding neither restores with the reason "the lane queue held no record for this lane".
_source_attempt carries the queued lane's outcome on _Attempt.lane_record, and _record_lesson takes it as a typed argument in place of session_sink["lesson"].
_record_lesson reads the iteration number from its result rather than from a separate argument, which keeps it within the code-metrics gate's five-parameter limit.
A new contract test runs a lane whose session sink has no summarizer through lane_queue.json into _record_lesson.
The long-horizon state and autonomous-loop docs say the queue carries the record, or the reason there is none.

Observable effect: a lane without a record now gets a fallback lesson that names why it has none (for example "provider cannot resume the session") instead of "the lane's session gave no record in its round"; lane_queue.json carries lesson_reason; and a lane agent that does not accept session_sink now fails instead of silently losing its lesson.

Co-authored-by: Cursor <cursoragent@cursor.com>
…the rebased code

Six units this branch touches are shorter than the baselines origin/main records. The baseline only moves down, so record the sizes the rebased tree has.

Observable effect: none
@amd-ama10002-2
amd-ama10002-2 force-pushed the feat/kernelforge-experience-kb branch from 73c7742 to de723f6 Compare October 24, 2026 00:26

@chaojhou chaojhou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the blocking issue in my review comment above: [free:verbatim-meta-strict-screen-skips]. The whole meta.json is exported into provenance.extra.meta and screened with the strict configuration rules, so candidates with a token-named case id or a "per token:" plan are skipped as sensitive content. Re-checked at this head (rebased onto main, same non-baseline patch-id as 73c7742): src/kernelforge/run_export/forge_loop.py:234, src/hyperloom_kb/mappings/kernelforge-v1.yaml:46 and :59, src/hyperloom_kb/collect/sensitive.py:72 are unchanged.

Head: de723f6

…ollecting a run

Collection published the whole meta.json, so a case id such as tokens_128_e8 or a plan that says "per token:" was skipped as a credential. An Experience now keeps the scalar fields, and the plan is screened as prose; the archive is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
@amd-ama10002-2

Copy link
Copy Markdown
Collaborator Author

#2037 (review)

[free:verbatim-meta-strict-screen-skips] Collection now copies only the scalar fields of meta.json onto provenance.extra.meta. The plan text and the plan file are screened as prose. The on-disk meta.json is unchanged.

export-runs still attaches that whole file to the run document. Collection leaves out plan, rationale, and bench, so a case id such as tokens_128_e8 or a plan of Summary: per token: one scale. is collected instead of skipped. The declaration comment is the only edit to kernelforge-run-v1.yaml, so schema_ref stays schema:sha256:090fe777e98b33e0487d53307df1bf72bafed2954a60826c3f7b84eb34387481. The changes are on 06a4ad0f5.

Provenance fields an Experience keeps

The declaration's provenance comment:

# provenance.extra holds the candidate's metadata, which never reaches a prompt: meta, the scalar measurement and
# decision fields of the candidate's meta.json (commit_hash, decision, kept, validation_passed, wall_ms,
# mean_case_speedup, snr_db, vgpr, baseline_wall_ms, best_wall_ms_before, best_mean_case_speedup_before,
# delta_vs_best_pct, session_end_reason, turns, agent_profile_id, agent_provider, agent_model, blockage_epoch), not
# its plan, rationale, bench, profile, pmc_diagnosis, or shape; kb_record, the record KernelForge's own experience KB
# filed a kept candidate under; candidate_origin, session or lane; analysis_generation, the Analysis generation
# reflection.analysis_report comes from, as analysis/<commit>/generation-NNN under forge_experiments; and
# analysis_report_omitted, why a report too long for a text field is not held.

The mapping's provenance.extra projection (src/hyperloom_kb/mappings/kernelforge-v1.yaml):

    extra:
      compact:
        object:
          candidate_origin: $run.provenance.candidate_origin
          kb_record: $run.provenance.kb_record
          analysis_generation: $run.provenance.analysis_generation
          analysis_report_omitted: $run.provenance.analysis_report_omitted
          meta:
            compact:
              object:
                commit_hash: $run.provenance.meta.commit_hash
                decision: $run.provenance.meta.decision
                kept: $run.provenance.meta.kept
                validation_passed: $run.provenance.meta.validation_passed
                wall_ms: $run.provenance.meta.wall_ms
                mean_case_speedup: $run.provenance.meta.mean_case_speedup
                snr_db: $run.provenance.meta.snr_db
                vgpr: $run.provenance.meta.vgpr
                baseline_wall_ms: $run.provenance.meta.baseline_wall_ms
                best_wall_ms_before: $run.provenance.meta.best_wall_ms_before
                best_mean_case_speedup_before: $run.provenance.meta.best_mean_case_speedup_before
                delta_vs_best_pct: $run.provenance.meta.delta_vs_best_pct
                session_end_reason: $run.provenance.meta.session_end_reason
                turns: $run.provenance.meta.turns
                agent_profile_id: $run.provenance.meta.agent_profile_id
                agent_provider: $run.provenance.meta.agent_provider
                agent_model: $run.provenance.meta.agent_model
                blockage_epoch: $run.provenance.meta.blockage_epoch
Example Experience collected from a token-named case and a per-token plan

Collected with collect(..., dry_run=True) from kernelforge export-runs on the export fixture. No campaign log. The candidate's meta.json has bench.case_times.tokens_128_e8 and plan Summary: per token: one scale., and the plan file is that line. Collection stores the Summary line with the label removed, so rationale.plan is per token: one scale., and copies only the scalar fields onto provenance.extra.meta (mean_case_speedup is there; plan, rationale, and bench are not).

{
  "kind": "experience",
  "id": "exp-8afd082519ed5e1a90811bb841d5ddef",
  "schema_ref": "schema:sha256:090fe777e98b33e0487d53307df1bf72bafed2954a60826c3f7b84eb34387481",
  "schema_version": 2,
  "status": "complete",
  "created_at": "2026-10-15T10:00:00Z",
  "completed_at": "2026-10-15T10:04:30Z",
  "run_id": "5f1c0a9e2b7d4c38a6e0f1b2c3d4e5f6",
  "seq": 1,
  "parent_id": "",
  "supersedes": "",
  "objective": "kernel_time_ms@v1",
  "rendered_refs": [],
  "notes": {},
  "provenance": {
    "producer": "kernelforge",
    "producer_version": "10",
    "model": "",
    "prompt_version": "",
    "snapshot_version": "",
    "source_ref": "forge_loop_triton_softmax_1760500000/iteration-1",
    "extra": {
      "candidate_origin": "session",
      "meta": {
        "commit_hash": "abc1234",
        "decision": "KEEP",
        "kept": true,
        "validation_passed": true,
        "wall_ms": 0.044,
        "mean_case_speedup": 1.18,
        "baseline_wall_ms": 0.052,
        "best_wall_ms_before": 0.052,
        "best_mean_case_speedup_before": 1.0
      }
    }
  },
  "raw_logs": [
    {
      "name": "raw-logs-not-collected.log",
      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "bytes": 0
    }
  ],
  "identity": {
    "operator": "_softmax_kernel",
    "kernel": "softmax",
    "backend": "triton",
    "gpu": "mi300x"
  },
  "baseline": {
    "best_wall_ms_before": 0.052,
    "source_ms": 0.052
  },
  "rationale": {
    "reasoning": "One sweep saved a global read. Small rows gained most. More.\nHELD-FIXED: the output layout.\nNEGATIVES: a second pass over the row.",
    "plan": "per token: one scale.",
    "plan_file": {
      "name": "optimization_plan.md",
      "sha256": "5431453a14e09145b42559ab9ba655991e585adeeafb73f5b0d8033b75c81dee",
      "bytes": 31
    }
  },
  "change": {
    "source_code": {
      "name": "softmax_kernel.py",
      "sha256": "8c11f77dd3ad9c1b34f2358d7f659b17a7b8c49cf7f9783987243b8ba41aefc9",
      "bytes": 14
    },
    "change_diff_file": {
      "name": "change.diff",
      "sha256": "b0cf3bd3aaf4a8e3a8c5542d888534f1df3b735abc8808698391a3c111d57a10",
      "bytes": 48
    }
  },
  "outcome": {
    "decision": "KEEP",
    "candidate_wall_ms": 0.044
  },
  "reflection": {}
}

@amd-ama10002-2
amd-ama10002-2 requested a review from a team October 24, 2026 01:56

@chaojhou chaojhou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #2037 -- feat(kernelforge): collect KernelForge runs into the Experience KB

What it does: adds kernelforge export-runs. It finds every plain forge-loop forge_experiments/ run under the log roots and writes one unit per archived candidate, carrying times, plan, lesson, analysis report, kernel and diff, the candidate's meta.json, and the kb_record of kept candidates. The kernelforge-run-v1 declaration and kernelforge-v1 mapping turn each unit into one Experience, with an idempotent id from (producer, campaign_id, iteration). To share formats with the exporter, the forge loop takes its paths, log lines and decision labels from owner modules (experiments_layout.py, loop_log.py, DecisionLabel), and a lane's lesson turn now runs inside the round.

Blocking issues: 2

The earlier blocker [free:verbatim-meta-strict-screen-skips] is resolved at 06a4ad0. rationale.plan and rationale.plan_file are now screened as prose (src/hyperloom_kb/collect/sensitive.py:72), and provenance.extra.meta carries an allowlist of scalar fields instead of the whole meta.json, which also keeps failed-bench output and its local paths out of the KB. The two findings below were not in that review; src/kernelforge/run_export/forge_loop.py is unchanged since 73c7742, so both still hold.

  1. [X1] kb_record is silently missing for every controller in-place run [verified]
    Problem: a forge-loop.log is attributed to a run by matching the trailing path components of the workspace it names against the archive's parent directory (src/kernelforge/run_export/forge_loop.py:80-96, _owner). For an in-place controller task, such as the editable aiter in the SGLang images, forge-loop runs in /sgl-workspace/aiter. _archive_campaign_output (src/kernelforge/kernel_rewrite_controller/worktree.py:366-382) then moves forge_experiments under the controller's archive_dir (.../workspaces/<operator>/). The two paths share no trailing component, so _owner returns None and no log is attached.
    Impact: kept candidates from the main Hyperloom-driven path reach the KB without the kb_record that the declaration and docs promise, and nothing in Known limitations, the declaration or docs/kernelforge/reference/cli.md says so.
    Action: attribute the log by something the archive keeps (the campaign_id in run_state.json, or the operator/workspace recorded in campaign_config.json). Or state the limitation in Known limitations, the declaration's kb_record text and cli.md.

  2. [X2] The description promises an entry for a run whose records are missing; none is produced [verified]
    Problem: the description says "A run or candidate whose JSON records are missing or malformed becomes an entry saying why". _experiments_dirs (src/kernelforge/run_export/forge_loop.py:146-153) recognises a run only when campaign_config.json sits under forge_experiments/ and run_state.json is a file. A run missing either is skipped without a read_error unit. Only malformed run records and a candidate's missing or unreadable meta.json produce an entry.
    Impact: an operator exporting a run that crashed before writing run_state.json gets "wrote 0 runs" and nothing explaining why, contrary to the description.
    Action: correct that sentence of the description, or emit a read_error unit for a forge_experiments directory that has candidates but no run_state.json.

Checked:

  • every requested change in the zili-amd review thread, verified as addressed at head;
  • the export path end to end: run_export/*, the declaration and mapping, and collect's screening and id derivation on main;
  • the existing KernelForge changes function by function against main. The prompt, gate-tag and fallback moves are verbatim, layout and log lines are byte-identical, and the behaviour changes are stated;
  • CI at 06a4ad0: all green (35 pass, 7 skipped).

Ran:

  • the new export and collect tests at head;
  • probe exports of fixture runs (a token-named case id, a 'per token:' plan, BUILD_FAILED/CRASH candidates, zero candidates, a missing run_state.json) through collect(..., dry_run=True);
  • all failing CI job logs.

Head: 06a4ad0

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size-exception Waives the PR diff budget; added by a maintainer with a reason in the PR description

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants