Skip to content

Add self-consistency inferlet + GoT batching + GSM8K benchmark harness#461

Open
zatchbell1311-wq wants to merge 8 commits into
pie-project:mainfrom
zatchbell1311-wq:benchmark/sc-vs-got-gsm8k
Open

Add self-consistency inferlet + GoT batching + GSM8K benchmark harness#461
zatchbell1311-wq wants to merge 8 commits into
pie-project:mainfrom
zatchbell1311-wq:benchmark/sc-vs-got-gsm8k

Conversation

@zatchbell1311-wq

@zatchbell1311-wq zatchbell1311-wq commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a self-consistency inferlet to compare against Graph-of-Thought (GoT), plus a crash-resilient benchmark harness to evaluate both on GSM8K at matched token budgets.

Changes

  • New self-consistency inferlet: generates N proposals in parallel (batched in chunks of 4 to reduce peak VRAM), extracts final answers, and takes a majority vote.
  • Batched GoT proposal generation: graph-of-thought/src/lib.rs now generates proposals in chunks of 4 instead of all-at-once, reducing peak concurrent KV cache usage on memory-constrained GPUs (tested on a 4GB RTX 2050).
  • benchmark_gsm8k.py: crash-resilient harness that runs both inferlets on GSM8K questions at matched ~3,584 token budgets, saves results incrementally (survives server crashes/restarts), and auto-restarts pie serve on failure.
  • analyze_results.py / install_inferlets.py: supporting scripts for result analysis and inferlet installation.
  • gsm8k_test.jsonl: GSM8K test split used for the benchmark.

Results (N=50, matched ~3,584 token budget)

12/50 questions failed due to WSL2/CUDA infrastructure crashes (unrelated to model behavior) and are excluded from accuracy figures below.

Method Correct Wrong NO_CONSENSUS Accuracy (of 38 valid)
Self-consistency 18 6 14 47.4%
Graph-of-Thought 9 1 28 23.7%

Self-consistency outperforms GoT by ~2x on this benchmark. Notably, GoT's shortfall is almost entirely a non-convergence problem rather than wrong answers (1 wrong vs 28 NO_CONSENSUS) — still investigating whether this is an extraction/parsing issue in the aggregation output or genuine reasoning degradation during pairwise aggregation at this model scale (Qwen3-0.6B).

Known limitations

  • Benchmark run on a 4GB VRAM GPU under WSL2, which caused intermittent dxgvmb_send_wait_sync_object_gpu stalls unrelated to this code; harness auto-recovers via retry logic but 12/50 questions were still lost to repeated crashes.
  • Root cause of GoT's high NO_CONSENSUS rate is not yet fully diagnosed (open follow-up).

Summary by CodeRabbit

  • New Features
    • Added benchmarking tools to run GSM8K question sets (baseline, self-consistency, and graph-of-thought), resume results, and analyze runs.
    • Added a new self-consistency inference mode and refactored graph-of-thought aggregation with voting and token accounting.
    • Added scripts to install inferlets locally and produce/consume benchmark result files.
  • Bug Fixes
    • Improved robustness of answer parsing and handling of missing predictions during evaluation.
  • Chores
    • Updated benchmark result JSON formats and added new benchmark datasets for convenience.
  • Refactor
    • Reduced scheduler graph budget in the portable driver to lower memory usage.

zatchbell1311-wq and others added 7 commits June 26, 2026 15:29
…llision crash

Pie's portable driver allocates a fresh ggml context arena per request.
ggml's CUDA graph cache keys captured graphs by the raw pointer address
of the graph's first node (cgraph->nodes[0]) — when a new request's
first-node address happens to coincide with a previous (now-freed)
request's address, the cache incorrectly treats the new, differently-
shaped graph as identical to the cached one, skips rebuilding it, and
replays a stale CUDA graph against mismatched tensor shapes.

This crashed pie-server reliably (via different GGML_ASSERT failures
depending on which shapes collided) after 1-2 successful requests to
the same inferlet, or under moderate concurrency.

This is a known upstream ggml/llama.cpp issue (ggml-org/llama.cpp
#20315, #19708) — the maintainers' sanctioned workaround is disabling
CUDA graph capture via GGML_CUDA_GRAPHS=OFF, which this commit forces
at the Pie build.rs level for the portable driver's CUDA backend.

Verified: before this fix, 6 identical repeated requests to the same
inferlet crashed the server on request 2 every time. After, 6 repeated
requests (self-consistency, 4-8 concurrent proposal contexts) and 3
repeated requests (graph-of-thought, 14 generation calls each) all
completed cleanly with consistent correct outputs and no crashes.

Tradeoff: disables a ~10-15% raw decode throughput optimization in
exchange for correctness. Given crashes make the backend unusable for
any sustained workload, this is a clear net win; a more surgical fix
(incorporating cgraph->n_nodes or another shape-derived value into the
cache key, matching the graph_recompute fix suggested upstream) could
restore graph-reuse performance for genuinely-repeated shapes as a
follow-up.
…mark harness

- New self-consistency inferlet (8 parallel proposals, majority vote)
- Batch GoT/SC proposal generation in chunks of 4 to reduce peak VRAM
- Add benchmark_gsm8k.py: crash-resilient harness comparing SC vs GoT
  at matched token budgets (~3584 tokens), with incremental result saving
- Add gsm8k_test.jsonl, analyze_results.py, install_inferlets.py
- Results: SC 47.4% vs GoT 23.7% accuracy on 38 valid questions
  (12/50 excluded due to WSL2/CUDA infra crashes, unrelated to model behavior)
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds GSM8K benchmarking and analysis scripts, recorded benchmark datasets, a self-consistency inferlet, a reworked graph-of-thought inferlet, and CUDA/portable build configuration changes.

Changes

GSM8K Benchmark and Analysis Tooling

Layer / File(s) Summary
Benchmark data and parsing
benchmark_gsm8k.py
Loads GSM8K examples, parses ground truth and inferlet outputs, and resumes from saved benchmark rows.
Server lifecycle and inferlet installation
benchmark_gsm8k.py, install_inferlets.py
Starts, checks, restarts, and reinitializes the local Pie server and installs the inferlets.
Question execution and result sets
benchmark_gsm8k.py, benchmark_results*.json
Runs baseline, self-consistency, and graph-of-thought calls per question, retries failures, computes metrics, and stores benchmark result arrays.
Result analysis
analyze_results.py
Prints accuracy, NO_CONSENSUS rates, head-to-head counts, and budget-artifact counters from benchmark JSON.

New Self-Consistency Inferlet

Layer / File(s) Summary
Crate and package manifests
inferlets/self-consistency/Cargo.toml, inferlets/self-consistency/Pie.toml
Defines the crate metadata, dependencies, runtime requirements, and parameter schema.
Input schema and answer parsing
inferlets/self-consistency/src/lib.rs
Defines the input schema, prompt text, think-block stripping, final-answer extraction, and majority voting helpers.
Concurrent proposal generation and output
inferlets/self-consistency/src/lib.rs
Runs proposal batches concurrently, accumulates token counts, logs extracted answers, and returns JSON containing the answer and tokens.

Graph-of-Thought Aggregation Rework

Layer / File(s) Summary
Output post-processing helpers
inferlets/graph-of-thought/src/lib.rs
Updates imports and adds think-block stripping, final-answer extraction, and numeric majority voting helpers.
Batched concurrent aggregation pipeline
inferlets/graph-of-thought/src/lib.rs
Creates aggregation pairs concurrently, runs proposal and aggregation stages with join-all batching, and updates main to emit the final JSON answer and token count.

Build Configuration Changes

Layer / File(s) Summary
Gate FlashInfer MoE kernel build
driver/cuda/CMakeLists.txt
Adds a build option, conditionally builds the MoE object library, and falls back to a stub source when disabled.
Portable driver CUDA settings
driver/portable/src/graph_common.hpp, server/build.rs
Lowers the graph node budget and disables CUDA graphs in the portable build.

Related Issues: None referenced in the provided information.
Related PRs: None referenced in the provided information.
Suggested labels: benchmarking, inferlets, build-config
Suggested reviewers: None specified.

Sequence Diagram(s)

sequenceDiagram
  participant benchmark_gsm8k.py
  participant pie serve
  participant self-consistency inferlet
  participant graph-of-thought inferlet

  benchmark_gsm8k.py->>pie serve: start / ping / restart
  benchmark_gsm8k.py->>self-consistency inferlet: run baseline and sc questions
  self-consistency inferlet-->>benchmark_gsm8k.py: predictions and tokens
  benchmark_gsm8k.py->>graph-of-thought inferlet: run got question
  graph-of-thought inferlet-->>benchmark_gsm8k.py: prediction and tokens
  benchmark_gsm8k.py->>benchmark_gsm8k.py: save results and print metrics
Loading
sequenceDiagram
  participant Input
  participant main
  participant Model
  participant majority_vote

  Input->>main: question and proposal_tokens
  main->>Model: generate proposal texts
  main->>main: strip think blocks and extract answers
  main->>majority_vote: parsed numeric answers
  majority_vote-->>main: winner or none
Loading

Poem:
A benchmark script now counts the run,
While inferlets vote on numbers one by one.
One path aggregates in batches, not in stream,
And build flags choose the kernel or the stubbed scheme.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main additions: the self-consistency inferlet, GoT batching, and GSM8K benchmark harness.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 11

♻️ Duplicate comments (1)
benchmark_results.json (1)

1-401: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Recorded data can't distinguish infrastructure failures from genuine NO_CONSENSUS.

Rows where a question failed due to server crashes (per the PR description, 12/50 questions) are stored with sc_pred/got_pred as null, identical to rows where the model itself failed to reach consensus. This is a direct downstream consequence of parse_prediction in benchmark_gsm8k.py not distinguishing the "SERVER_ERROR" sentinel from a real NO_CONSENSUS result — see the corresponding comment on that file. Without a status field, this dataset can't be reliably re-analyzed to separate the two causes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_results.json` around lines 1 - 401, The benchmark output currently
mixes server crashes with genuine NO_CONSENSUS cases because `parse_prediction`
in `benchmark_gsm8k.py` converts the `"SERVER_ERROR"` sentinel into the same
`null` prediction shape as a real consensus failure. Update the data recording
path to preserve the outcome type for each row, using a distinct status/flag
from `parse_prediction` and the benchmark result writer so `sc_pred`/`got_pred`
no longer lose error provenance. Make sure the JSON emitted for each question
clearly distinguishes infrastructure failure from model disagreement without
relying on `null` alone.
🧹 Nitpick comments (5)
inferlets/graph-of-thought/src/lib.rs (1)

114-121: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead constant AGGREGATE_PROMPT.

The constant is empty and self-described as unused; the prompt is built inline in launch_aggregation_pairs. Consider removing it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/graph-of-thought/src/lib.rs` around lines 114 - 121, The empty
unused constant AGGREGATE_PROMPT in lib.rs should be removed to avoid dead code
and confusion. Update the prompt handling in the graph-of-thought module by
deleting AGGREGATE_PROMPT and relying on the existing format_aggregate_prompt
path used by launch_aggregation_pairs; keep SYSTEM_PROMPT and
PROPOSAL_PROMPT_TEMPLATE unchanged.
install_inferlets.py (1)

1-20: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicates hardcoded install logic already present in benchmark_gsm8k.py's install_inferlets().

Both this file and install_inferlets() (benchmark_gsm8k.py, lines 105-109) hardcode the same /home/dhruv/... wasm/manifest paths and perform the identical two-step install. Consider having benchmark_gsm8k.py import/reuse this script's logic (or vice versa) to avoid the two copies drifting out of sync, and address the portability concern (hardcoded absolute paths) in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@install_inferlets.py` around lines 1 - 20, The install flow is duplicated
with hardcoded absolute paths, so update the shared install logic instead of
keeping two copies in sync. Refactor the existing main() in install_inferlets.py
or benchmark_gsm8k.py’s install_inferlets() to reuse a single helper for the two
client.install_program calls, and centralize the wasm/manifest paths so they are
defined once and can be made portable.
server/build.rs (1)

133-141: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Comment now contradicts the code — update to explain why CUDA graphs are disabled.

Lines 135-139 justify enabling CUDA graphs for perf, but line 140 now sets GGML_CUDA_GRAPHS to "OFF". Per the PR's commit context, this was an intentional fix for a CUDA-graph pointer-key-collision crash under Pie's request-per-context model — but the comment still describes the opposite rationale, which will confuse future maintainers into re-enabling this and reintroducing the crash.

📝 Suggested comment update
-            // ggml's CUDA-graph capture/replay is OFF by default at the ggml-
-            // subproject level; llama.cpp's parent CMakeLists.txt flips it
-            // ON. Embedding ggml directly (as we do here) misses that override
-            // and ends up issuing ~30 kernel launches per decode step, which
-            // costs ~1 ms vs ~0.3 ms with capture. Enable explicitly.
-            .define("GGML_CUDA_GRAPHS", "OFF")
+            // CUDA-graph capture/replay is disabled: under Pie's
+            // request-per-context model, repeated graph capture/replay
+            // triggers a pointer-key collision in ggml's CUDA graph node
+            // cache, causing crashes on repeated requests. Costs ~1ms vs
+            // ~0.3ms per decode step without capture; re-enable only after
+            // the upstream key-collision issue is fixed.
+            .define("GGML_CUDA_GRAPHS", "OFF")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/build.rs` around lines 133 - 141, The comment in the CUDA config block
contradicts the actual GGML_CUDA_GRAPHS setting in build.rs. Update the
explanatory comment around the cfg.define chain in the CUDA-enabled branch to
describe why CUDA graphs are intentionally set to OFF, referencing the Pie
request-per-context pointer-key-collision crash rationale instead of the
performance claim, so future changes to cuda_enabled and GGML_CUDA_GRAPHS are
not misled.
inferlets/self-consistency/Pie.toml (1)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc says "must be even" but this isn't enforced anywhere.

The description states the proposal_tokens array length "must be even," but inferlets/self-consistency/src/lib.rs never validates this — an odd-length array (e.g. 3 proposals) is silently accepted and works fine for majority voting. Either enforce the constraint or drop the misleading wording.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/self-consistency/Pie.toml` at line 13, The Pie.toml description for
proposal_tokens says the proposal count “must be even,” but SelfConsistency in
inferlets/self-consistency/src/lib.rs does not enforce that rule and odd-length
proposal arrays are accepted. Update the documentation to remove the misleading
“must be even” wording, or add a validation check in the SelfConsistency
parsing/handling path to reject odd proposal counts so the config and runtime
behavior match.
inferlets/self-consistency/src/lib.rs (1)

90-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two divergent vote-grouping algorithms.

majority_vote (Lines 90-105) groups floats using an epsilon comparison (abs() < 1e-9), while vote_counts (Lines 162-165) groups the same values using a format!("{:.6}", ans) string key. These two groupings can diverge for edge-case floats (e.g., values differing by less than 1e-9 but rounding to different 6-decimal strings, or vice versa), making the printed "Vote counts" diagnostic potentially inconsistent with the actual winner. Consider deriving vote_counts from the same grouping used by majority_vote to keep the debug output and decision logic in sync.

Also applies to: 162-166

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/self-consistency/src/lib.rs` around lines 90 - 105, `majority_vote`
and the `vote_counts` diagnostic are using different float-grouping rules, which
can make the displayed counts disagree with the selected winner. Update
`vote_counts` in `infer_one` to derive its groups from the same epsilon-based
comparison logic used by `majority_vote` (or factor that grouping into a shared
helper) so both the debug output and the vote decision stay consistent. Use the
existing `majority_vote` and `vote_counts` symbols to keep the grouping behavior
aligned for all `Option<f64>` answers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@analyze_results.py`:
- Around line 8-29: Guard the summary printing in analyze_results.py against an
empty rows list before computing percentages. In the main analysis block where
n, sc_correct, got_correct, sc_no_consensus, and got_no_consensus are used, add
an early exit or conditional branch for n == 0 so the f-strings in the
Self-consistency and Graph-of-Thought sections never divide by zero. Keep the
existing output structure in the non-empty case and reference the same
counters/metrics already computed.

In `@benchmark_gsm8k.py`:
- Around line 190-198: The aggregate summary in the results block uses
n_questions as the denominator even though the actual evaluated data is in rows,
which can become stale when resuming with fewer questions than an existing run.
Update the summary logic near the results printout in benchmark_gsm8k.py to
compute totals and percentages from len(rows) (or otherwise clamp to the
evaluated row count) instead of n_questions, and keep the displayed N consistent
with the actual number of processed rows. Use the existing variables sc_correct,
got_correct, sc_no_consensus, got_no_consensus, and rows to locate the fix.
- Around line 69-79: The start_server function leaks the SERVE_LOG file handle
because log_f is opened in the parent and never closed after subprocess.Popen
starts the server. Update start_server so the parent closes or otherwise manages
the log file handle immediately after spawning the child, while still preserving
stdout/stderr redirection via PIE_BIN and Popen. Make the fix in start_server
and ensure restart_server_and_wait can call it repeatedly without accumulating
open descriptors.
- Around line 13-21: Replace the developer-specific absolute constants in
benchmark_gsm8k.py with paths derived from the repository/runtime environment
instead of /home/dhruv/... so the script works across machines and CI. Update
the path setup near GSM8K_PATH, RESULTS_PATH, CONFIG_PATH, SERVE_LOG, PIE_BIN,
SC_WASM, SC_MANIFEST, GOT_WASM, and GOT_MANIFEST to be computed from the project
root or configurable via environment variables/CLI arguments. Keep the existing
symbol names if helpful, but make their values portable and
checkout-independent.
- Around line 48-54: The current parse_prediction flow collapses SERVER_ERROR
and NO_CONSENSUS into the same None result, so update parse_prediction and the
downstream result handling in benchmark_gsm8k/analyze_results to preserve a
separate error/status marker for SERVER_ERROR. Keep NO_CONSENSUS mapped as the
model non-answer, but store SERVER_ERROR distinctly in the saved results and
budget-artifact counts so analyze_results can count it separately instead of
treating all None values the same.

In `@driver/cuda/CMakeLists.txt`:
- Around line 370-438: The fallback branch in the PIE_CUDA_BUILD_MOE block
references a non-existent stub source, so the default OFF path breaks the build.
Update the else branch around target_sources(pie_driver_cuda_lib) to point at
the actual stub implementation used for flashinfer_moe, or add the missing stub
file under the existing src/ops naming expected by flashinfer_moe_stub.cpp. Make
sure the symbols PIE_CUDA_BUILD_MOE, pie_driver_cuda_lib, and
flashinfer_moe_stub.cpp all resolve consistently.

In `@driver/portable/src/graph_common.hpp`:
- Around line 22-24: GRAPH_MAX_NODES is now too low for supported Qwen3
large-batch paths and can under-size both graph construction and the backend
scheduler buffer. Update the constant in graph_common.hpp to preserve a much
larger headroom, or refactor the budget to be model/batch-specific using the
existing graph_qwen3.cpp sizing logic so large request counts do not fail at
runtime.

In `@inferlets/graph-of-thought/src/lib.rs`:
- Around line 94-112: The tie-break behavior in majority_vote is documented as
first-occurrence, but max_by_key will pick the last maximal entry among ties.
Update majority_vote so the tie-break is explicit and matches the intended
first-seen order, or adjust the doc comment to reflect the actual
last-occurrence behavior; keep the change localized to majority_vote and its
comment.
- Around line 74-92: `extract_final_answer` is slicing from `cleaned` using an
index found in the lowercased buffer, which can misalign for Unicode because
`to_lowercase()` may change byte length. Update `extract_final_answer` to derive
the substring from the same lowercase string used for `rfind`, or otherwise map
the index safely before slicing, and make the same correction in the matching
`extract_final_answer` implementation in
`inferlets/self-consistency/src/lib.rs`.

In `@inferlets/self-consistency/Pie.toml`:
- Line 13: The manifest schema for proposal_tokens is declared as a scalar
string, but inferlets/self-consistency/src/lib.rs expects a Vec<usize>, so
update the Pie.toml declaration to a list-capable array schema for
proposal_tokens or align the inferlet deserialization to a scalar format. Use
the proposal_tokens field definition in Pie.toml and the corresponding parsing
logic in the self-consistency lib as the matching points, and make sure the
manifest-driven input shape matches the actual JSON array expected by the
inferlet.

In `@inferlets/self-consistency/src/lib.rs`:
- Around line 67-85: `extract_final_answer` is slicing `cleaned` using an index
computed from `to_lowercase()`, which can become invalid for Unicode text;
update the marker search to use a byte-preserving approach or a case-insensitive
search on the original string, and then derive the `after` slice from the same
source string so `rfind`/slice offsets stay aligned.

---

Duplicate comments:
In `@benchmark_results.json`:
- Around line 1-401: The benchmark output currently mixes server crashes with
genuine NO_CONSENSUS cases because `parse_prediction` in `benchmark_gsm8k.py`
converts the `"SERVER_ERROR"` sentinel into the same `null` prediction shape as
a real consensus failure. Update the data recording path to preserve the outcome
type for each row, using a distinct status/flag from `parse_prediction` and the
benchmark result writer so `sc_pred`/`got_pred` no longer lose error provenance.
Make sure the JSON emitted for each question clearly distinguishes
infrastructure failure from model disagreement without relying on `null` alone.

---

Nitpick comments:
In `@inferlets/graph-of-thought/src/lib.rs`:
- Around line 114-121: The empty unused constant AGGREGATE_PROMPT in lib.rs
should be removed to avoid dead code and confusion. Update the prompt handling
in the graph-of-thought module by deleting AGGREGATE_PROMPT and relying on the
existing format_aggregate_prompt path used by launch_aggregation_pairs; keep
SYSTEM_PROMPT and PROPOSAL_PROMPT_TEMPLATE unchanged.

In `@inferlets/self-consistency/Pie.toml`:
- Line 13: The Pie.toml description for proposal_tokens says the proposal count
“must be even,” but SelfConsistency in inferlets/self-consistency/src/lib.rs
does not enforce that rule and odd-length proposal arrays are accepted. Update
the documentation to remove the misleading “must be even” wording, or add a
validation check in the SelfConsistency parsing/handling path to reject odd
proposal counts so the config and runtime behavior match.

In `@inferlets/self-consistency/src/lib.rs`:
- Around line 90-105: `majority_vote` and the `vote_counts` diagnostic are using
different float-grouping rules, which can make the displayed counts disagree
with the selected winner. Update `vote_counts` in `infer_one` to derive its
groups from the same epsilon-based comparison logic used by `majority_vote` (or
factor that grouping into a shared helper) so both the debug output and the vote
decision stay consistent. Use the existing `majority_vote` and `vote_counts`
symbols to keep the grouping behavior aligned for all `Option<f64>` answers.

In `@install_inferlets.py`:
- Around line 1-20: The install flow is duplicated with hardcoded absolute
paths, so update the shared install logic instead of keeping two copies in sync.
Refactor the existing main() in install_inferlets.py or benchmark_gsm8k.py’s
install_inferlets() to reuse a single helper for the two client.install_program
calls, and centralize the wasm/manifest paths so they are defined once and can
be made portable.

In `@server/build.rs`:
- Around line 133-141: The comment in the CUDA config block contradicts the
actual GGML_CUDA_GRAPHS setting in build.rs. Update the explanatory comment
around the cfg.define chain in the CUDA-enabled branch to describe why CUDA
graphs are intentionally set to OFF, referencing the Pie request-per-context
pointer-key-collision crash rationale instead of the performance claim, so
future changes to cuda_enabled and GGML_CUDA_GRAPHS are not misled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 573db40e-a7fb-46cb-a218-2e8c7b089833

📥 Commits

Reviewing files that changed from the base of the PR and between 317abc1 and aa82fc8.

📒 Files selected for processing (12)
  • analyze_results.py
  • benchmark_gsm8k.py
  • benchmark_results.json
  • driver/cuda/CMakeLists.txt
  • driver/portable/src/graph_common.hpp
  • gsm8k_test.jsonl
  • inferlets/graph-of-thought/src/lib.rs
  • inferlets/self-consistency/Cargo.toml
  • inferlets/self-consistency/Pie.toml
  • inferlets/self-consistency/src/lib.rs
  • install_inferlets.py
  • server/build.rs

Comment thread analyze_results.py
Comment on lines +8 to +29
n = len(rows)
sc_correct = sum(r["sc_correct"] for r in rows)
got_correct = sum(r["got_correct"] for r in rows)
sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
got_no_consensus = sum(r["got_pred"] is None for r in rows)

sc_answered = n - sc_no_consensus
got_answered = n - got_no_consensus

print(f"=== GSM8K Benchmark Analysis (N={n}) ===\n")

print("Self-consistency:")
print(f" Overall accuracy: {sc_correct}/{n} = {100*sc_correct/n:.1f}%")
print(f" NO_CONSENSUS rate: {sc_no_consensus}/{n} = {100*sc_no_consensus/n:.1f}%")
if sc_answered > 0:
print(f" Accuracy | answered: {sc_correct}/{sc_answered} = {100*sc_correct/sc_answered:.1f}%")

print("\nGraph-of-Thought:")
print(f" Overall accuracy: {got_correct}/{n} = {100*got_correct/n:.1f}%")
print(f" NO_CONSENSUS rate: {got_no_consensus}/{n} = {100*got_no_consensus/n:.1f}%")
if got_answered > 0:
print(f" Accuracy | answered: {got_correct}/{got_answered} = {100*got_correct/got_answered:.1f}%")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Division by zero on empty results file.

If rows is empty (n == 0), lines computing 100*sc_correct/n etc. will raise ZeroDivisionError.

🔧 Suggested fix
     n = len(rows)
+    if n == 0:
+        print("No results to analyze.")
+        return
     sc_correct = sum(r["sc_correct"] for r in rows)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
n = len(rows)
sc_correct = sum(r["sc_correct"] for r in rows)
got_correct = sum(r["got_correct"] for r in rows)
sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
got_no_consensus = sum(r["got_pred"] is None for r in rows)
sc_answered = n - sc_no_consensus
got_answered = n - got_no_consensus
print(f"=== GSM8K Benchmark Analysis (N={n}) ===\n")
print("Self-consistency:")
print(f" Overall accuracy: {sc_correct}/{n} = {100*sc_correct/n:.1f}%")
print(f" NO_CONSENSUS rate: {sc_no_consensus}/{n} = {100*sc_no_consensus/n:.1f}%")
if sc_answered > 0:
print(f" Accuracy | answered: {sc_correct}/{sc_answered} = {100*sc_correct/sc_answered:.1f}%")
print("\nGraph-of-Thought:")
print(f" Overall accuracy: {got_correct}/{n} = {100*got_correct/n:.1f}%")
print(f" NO_CONSENSUS rate: {got_no_consensus}/{n} = {100*got_no_consensus/n:.1f}%")
if got_answered > 0:
print(f" Accuracy | answered: {got_correct}/{got_answered} = {100*got_correct/got_answered:.1f}%")
n = len(rows)
if n == 0:
print("No results to analyze.")
return
sc_correct = sum(r["sc_correct"] for r in rows)
got_correct = sum(r["got_correct"] for r in rows)
sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
got_no_consensus = sum(r["got_pred"] is None for r in rows)
sc_answered = n - sc_no_consensus
got_answered = n - got_no_consensus
print(f"=== GSM8K Benchmark Analysis (N={n}) ===\n")
print("Self-consistency:")
print(f" Overall accuracy: {sc_correct}/{n} = {100*sc_correct/n:.1f}%")
print(f" NO_CONSENSUS rate: {sc_no_consensus}/{n} = {100*sc_no_consensus/n:.1f}%")
if sc_answered > 0:
print(f" Accuracy | answered: {sc_correct}/{sc_answered} = {100*sc_correct/sc_answered:.1f}%")
print("\nGraph-of-Thought:")
print(f" Overall accuracy: {got_correct}/{n} = {100*got_correct/n:.1f}%")
print(f" NO_CONSENSUS rate: {got_no_consensus}/{n} = {100*got_no_consensus/n:.1f}%")
if got_answered > 0:
print(f" Accuracy | answered: {got_correct}/{got_answered} = {100*got_correct/got_answered:.1f}%")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@analyze_results.py` around lines 8 - 29, Guard the summary printing in
analyze_results.py against an empty rows list before computing percentages. In
the main analysis block where n, sc_correct, got_correct, sc_no_consensus, and
got_no_consensus are used, add an early exit or conditional branch for n == 0 so
the f-strings in the Self-consistency and Graph-of-Thought sections never divide
by zero. Keep the existing output structure in the non-empty case and reference
the same counters/metrics already computed.

Comment thread benchmark_gsm8k.py
Comment on lines +13 to +21
GSM8K_PATH = "/home/dhruv/pie/gsm8k_test.jsonl"
RESULTS_PATH = "/home/dhruv/pie/benchmark_results.json"
CONFIG_PATH = "/home/dhruv/.pie/config.toml"
SERVE_LOG = "/home/dhruv/pie/serve.log"
PIE_BIN = "/home/dhruv/pie/target/release/pie"
SC_WASM = "/home/dhruv/pie/build-out/self_consistency.wasm"
SC_MANIFEST = "/home/dhruv/pie/inferlets/self-consistency/Pie.toml"
GOT_WASM = "/home/dhruv/pie/build-out/graph_of_thought.wasm"
GOT_MANIFEST = "/home/dhruv/pie/inferlets/graph-of-thought/Pie.toml"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Hardcoded developer-specific absolute paths.

All key paths (GSM8K_PATH, RESULTS_PATH, CONFIG_PATH, SERVE_LOG, PIE_BIN, wasm/manifest paths) are hardcoded to /home/dhruv/.... This makes the script unusable for anyone else without manual edits, and fragile if run from CI or another checkout location.

♻️ Suggested fix
-SERVER_URL = "ws://127.0.0.1:8080"
-GSM8K_PATH = "/home/dhruv/pie/gsm8k_test.jsonl"
-RESULTS_PATH = "/home/dhruv/pie/benchmark_results.json"
-CONFIG_PATH = "/home/dhruv/.pie/config.toml"
-SERVE_LOG = "/home/dhruv/pie/serve.log"
-PIE_BIN = "/home/dhruv/pie/target/release/pie"
-SC_WASM = "/home/dhruv/pie/build-out/self_consistency.wasm"
-SC_MANIFEST = "/home/dhruv/pie/inferlets/self-consistency/Pie.toml"
-GOT_WASM = "/home/dhruv/pie/build-out/graph_of_thought.wasm"
-GOT_MANIFEST = "/home/dhruv/pie/inferlets/graph-of-thought/Pie.toml"
+REPO_ROOT = Path(__file__).resolve().parent
+SERVER_URL = os.environ.get("PIE_SERVER_URL", "ws://127.0.0.1:8080")
+GSM8K_PATH = os.environ.get("GSM8K_PATH", str(REPO_ROOT / "gsm8k_test.jsonl"))
+RESULTS_PATH = os.environ.get("RESULTS_PATH", str(REPO_ROOT / "benchmark_results.json"))
+CONFIG_PATH = os.environ.get("PIE_CONFIG_PATH", str(Path.home() / ".pie" / "config.toml"))
+SERVE_LOG = os.environ.get("SERVE_LOG", str(REPO_ROOT / "serve.log"))
+PIE_BIN = os.environ.get("PIE_BIN", str(REPO_ROOT / "target" / "release" / "pie"))
+SC_WASM = str(REPO_ROOT / "build-out" / "self_consistency.wasm")
+SC_MANIFEST = str(REPO_ROOT / "inferlets" / "self-consistency" / "Pie.toml")
+GOT_WASM = str(REPO_ROOT / "build-out" / "graph_of_thought.wasm")
+GOT_MANIFEST = str(REPO_ROOT / "inferlets" / "graph-of-thought" / "Pie.toml")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
GSM8K_PATH = "/home/dhruv/pie/gsm8k_test.jsonl"
RESULTS_PATH = "/home/dhruv/pie/benchmark_results.json"
CONFIG_PATH = "/home/dhruv/.pie/config.toml"
SERVE_LOG = "/home/dhruv/pie/serve.log"
PIE_BIN = "/home/dhruv/pie/target/release/pie"
SC_WASM = "/home/dhruv/pie/build-out/self_consistency.wasm"
SC_MANIFEST = "/home/dhruv/pie/inferlets/self-consistency/Pie.toml"
GOT_WASM = "/home/dhruv/pie/build-out/graph_of_thought.wasm"
GOT_MANIFEST = "/home/dhruv/pie/inferlets/graph-of-thought/Pie.toml"
REPO_ROOT = Path(__file__).resolve().parent
SERVER_URL = os.environ.get("PIE_SERVER_URL", "ws://127.0.0.1:8080")
GSM8K_PATH = os.environ.get("GSM8K_PATH", str(REPO_ROOT / "gsm8k_test.jsonl"))
RESULTS_PATH = os.environ.get("RESULTS_PATH", str(REPO_ROOT / "benchmark_results.json"))
CONFIG_PATH = os.environ.get("PIE_CONFIG_PATH", str(Path.home() / ".pie" / "config.toml"))
SERVE_LOG = os.environ.get("SERVE_LOG", str(REPO_ROOT / "serve.log"))
PIE_BIN = os.environ.get("PIE_BIN", str(REPO_ROOT / "target" / "release" / "pie"))
SC_WASM = str(REPO_ROOT / "build-out" / "self_consistency.wasm")
SC_MANIFEST = str(REPO_ROOT / "inferlets" / "self-consistency" / "Pie.toml")
GOT_WASM = str(REPO_ROOT / "build-out" / "graph_of_thought.wasm")
GOT_MANIFEST = str(REPO_ROOT / "inferlets" / "graph-of-thought" / "Pie.toml")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_gsm8k.py` around lines 13 - 21, Replace the developer-specific
absolute constants in benchmark_gsm8k.py with paths derived from the
repository/runtime environment instead of /home/dhruv/... so the script works
across machines and CI. Update the path setup near GSM8K_PATH, RESULTS_PATH,
CONFIG_PATH, SERVE_LOG, PIE_BIN, SC_WASM, SC_MANIFEST, GOT_WASM, and
GOT_MANIFEST to be computed from the project root or configurable via
environment variables/CLI arguments. Keep the existing symbol names if helpful,
but make their values portable and checkout-independent.

Comment thread benchmark_gsm8k.py Outdated
Comment on lines +48 to +54
def parse_prediction(raw: str):
if raw == "NO_CONSENSUS":
return None
try:
return float(raw)
except (ValueError, TypeError):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files | rg '(^|/)(benchmark_gsm8k\.py|analyze_results\.py)$'

printf '\nbenchmark_gsm8k.py relevant lines:\n'
nl -ba benchmark_gsm8k.py | sed -n '1,260p'

printf '\nanalyze_results.py relevant lines:\n'
nl -ba analyze_results.py | sed -n '1,260p'

Repository: pie-project/pie

Length of output: 274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo 'benchmark_gsm8k.py:'
rg -n 'NO_CONSENSUS|SERVER_ERROR|parse_prediction|server_error|sc_raw|got_raw|analyze_results' benchmark_gsm8k.py

echo
echo 'analyze_results.py:'
rg -n 'NO_CONSENSUS|SERVER_ERROR|budget-artifact|server_error|parse_prediction|status|manual' analyze_results.py

Repository: pie-project/pie

Length of output: 1205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for path in [Path('benchmark_gsm8k.py'), Path('analyze_results.py')]:
    print(f'### {path}')
    text = path.read_text()
    for needle in ['NO_CONSENSUS', 'SERVER_ERROR', 'parse_prediction', 'status', 'sc_raw', 'got_raw', 'budget-artifact', 'manual']:
        if needle in text:
            print(f'contains {needle}')
    print()
PY

Repository: pie-project/pie

Length of output: 326


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo 'benchmark_gsm8k.py lines 40-210:'
sed -n '40,210p' benchmark_gsm8k.py

echo
echo 'analyze_results.py lines 1-220:'
sed -n '1,220p' analyze_results.py

Repository: pie-project/pie

Length of output: 8223


Keep SERVER_ERROR distinct from NO_CONSENSUS. SERVER_ERROR is converted to None by parse_prediction(), and analyze_results.py counts every None as NO_CONSENSUS. That makes retry exhaustion indistinguishable from a real model non-answer in the saved results and budget-artifact counts; store a separate error/status field instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_gsm8k.py` around lines 48 - 54, The current parse_prediction flow
collapses SERVER_ERROR and NO_CONSENSUS into the same None result, so update
parse_prediction and the downstream result handling in
benchmark_gsm8k/analyze_results to preserve a separate error/status marker for
SERVER_ERROR. Keep NO_CONSENSUS mapped as the model non-answer, but store
SERVER_ERROR distinctly in the saved results and budget-artifact counts so
analyze_results can count it separately instead of treating all None values the
same.

Comment thread benchmark_gsm8k.py
Comment on lines +69 to +79
def start_server():
"""Start pie serve in the background, wait for it to be ready."""
log_f = open(SERVE_LOG, "a")
env = os.environ.copy()
env["GGML_CUDA_GRAPHS"] = "0"
proc = subprocess.Popen(
[PIE_BIN, "serve", "--config", CONFIG_PATH, "--no-auth"],
stdout=log_f, stderr=subprocess.STDOUT,
env=env,
)
return proc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

File handle leak on repeated start_server calls.

log_f is opened but never closed in the parent process; subprocess.Popen duplicates the fd for the child, but the parent's copy stays open indefinitely. Since start_server is called repeatedly by restart_server_and_wait in the crash-recovery loop, each restart leaks one file descriptor, which can accumulate over a long benchmark run with many crashes.

🔧 Suggested fix
 def start_server():
     """Start pie serve in the background, wait for it to be ready."""
-    log_f = open(SERVE_LOG, "a")
     env = os.environ.copy()
     env["GGML_CUDA_GRAPHS"] = "0"
-    proc = subprocess.Popen(
-        [PIE_BIN, "serve", "--config", CONFIG_PATH, "--no-auth"],
-        stdout=log_f, stderr=subprocess.STDOUT,
-        env=env,
-    )
-    return proc
+    with open(SERVE_LOG, "a") as log_f:
+        proc = subprocess.Popen(
+            [PIE_BIN, "serve", "--config", CONFIG_PATH, "--no-auth"],
+            stdout=log_f, stderr=subprocess.STDOUT,
+            env=env,
+        )
+    return proc
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def start_server():
"""Start pie serve in the background, wait for it to be ready."""
log_f = open(SERVE_LOG, "a")
env = os.environ.copy()
env["GGML_CUDA_GRAPHS"] = "0"
proc = subprocess.Popen(
[PIE_BIN, "serve", "--config", CONFIG_PATH, "--no-auth"],
stdout=log_f, stderr=subprocess.STDOUT,
env=env,
)
return proc
def start_server():
"""Start pie serve in the background, wait for it to be ready."""
env = os.environ.copy()
env["GGML_CUDA_GRAPHS"] = "0"
with open(SERVE_LOG, "a") as log_f:
proc = subprocess.Popen(
[PIE_BIN, "serve", "--config", CONFIG_PATH, "--no-auth"],
stdout=log_f, stderr=subprocess.STDOUT,
env=env,
)
return proc
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 73-77: Command coming from incoming request
Context: subprocess.Popen(
[PIE_BIN, "serve", "--config", CONFIG_PATH, "--no-auth"],
stdout=log_f, stderr=subprocess.STDOUT,
env=env,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[warning] 70-70: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(SERVE_LOG, "a")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🪛 Ruff (0.15.20)

[error] 74-74: subprocess call: check for execution of untrusted input

(S603)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_gsm8k.py` around lines 69 - 79, The start_server function leaks the
SERVE_LOG file handle because log_f is opened in the parent and never closed
after subprocess.Popen starts the server. Update start_server so the parent
closes or otherwise manages the log file handle immediately after spawning the
child, while still preserving stdout/stderr redirection via PIE_BIN and Popen.
Make the fix in start_server and ensure restart_server_and_wait can call it
repeatedly without accumulating open descriptors.

Comment thread benchmark_gsm8k.py Outdated
Comment on lines +190 to +198
sc_correct = sum(r["sc_correct"] for r in rows)
got_correct = sum(r["got_correct"] for r in rows)
sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
got_no_consensus = sum(r["got_pred"] is None for r in rows)

print("\n=== Results ===")
print(f"N = {n_questions}")
print(f"Self-consistency: {sc_correct}/{n_questions} correct ({100*sc_correct/n_questions:.1f}%), {sc_no_consensus} NO_CONSENSUS")
print(f"Graph-of-Thought: {got_correct}/{n_questions} correct ({100*got_correct/n_questions:.1f}%), {got_no_consensus} NO_CONSENSUS")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Aggregate stats use n_questions instead of len(rows).

If resuming with a smaller n_questions than a previously completed run (already_done >= n_questions), the main loop never executes, but the aggregate section still divides by n_questions while rows may contain more entries, producing incorrect percentages/denominators.

🔧 Suggested fix
-    sc_correct = sum(r["sc_correct"] for r in rows)
-    got_correct = sum(r["got_correct"] for r in rows)
-    sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
-    got_no_consensus = sum(r["got_pred"] is None for r in rows)
-
-    print("\n=== Results ===")
-    print(f"N = {n_questions}")
-    print(f"Self-consistency: {sc_correct}/{n_questions} correct ({100*sc_correct/n_questions:.1f}%), {sc_no_consensus} NO_CONSENSUS")
-    print(f"Graph-of-Thought: {got_correct}/{n_questions} correct ({100*got_correct/n_questions:.1f}%), {got_no_consensus} NO_CONSENSUS")
+    total = len(rows)
+    sc_correct = sum(r["sc_correct"] for r in rows)
+    got_correct = sum(r["got_correct"] for r in rows)
+    sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
+    got_no_consensus = sum(r["got_pred"] is None for r in rows)
+
+    print("\n=== Results ===")
+    print(f"N = {total}")
+    print(f"Self-consistency: {sc_correct}/{total} correct ({100*sc_correct/total:.1f}%), {sc_no_consensus} NO_CONSENSUS")
+    print(f"Graph-of-Thought: {got_correct}/{total} correct ({100*got_correct/total:.1f}%), {got_no_consensus} NO_CONSENSUS")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sc_correct = sum(r["sc_correct"] for r in rows)
got_correct = sum(r["got_correct"] for r in rows)
sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
got_no_consensus = sum(r["got_pred"] is None for r in rows)
print("\n=== Results ===")
print(f"N = {n_questions}")
print(f"Self-consistency: {sc_correct}/{n_questions} correct ({100*sc_correct/n_questions:.1f}%), {sc_no_consensus} NO_CONSENSUS")
print(f"Graph-of-Thought: {got_correct}/{n_questions} correct ({100*got_correct/n_questions:.1f}%), {got_no_consensus} NO_CONSENSUS")
total = len(rows)
sc_correct = sum(r["sc_correct"] for r in rows)
got_correct = sum(r["got_correct"] for r in rows)
sc_no_consensus = sum(r["sc_pred"] is None for r in rows)
got_no_consensus = sum(r["got_pred"] is None for r in rows)
print("\n=== Results ===")
print(f"N = {total}")
print(f"Self-consistency: {sc_correct}/{total} correct ({100*sc_correct/total:.1f}%), {sc_no_consensus} NO_CONSENSUS")
print(f"Graph-of-Thought: {got_correct}/{total} correct ({100*got_correct/total:.1f}%), {got_no_consensus} NO_CONSENSUS")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_gsm8k.py` around lines 190 - 198, The aggregate summary in the
results block uses n_questions as the denominator even though the actual
evaluated data is in rows, which can become stale when resuming with fewer
questions than an existing run. Update the summary logic near the results
printout in benchmark_gsm8k.py to compute totals and percentages from len(rows)
(or otherwise clamp to the evaluated row count) instead of n_questions, and keep
the displayed N consistent with the actual number of processed rows. Use the
existing variables sc_correct, got_correct, sc_no_consensus, got_no_consensus,
and rows to locate the fix.

Comment on lines 22 to +24
// Per-call ggml graph node budget. Sized to comfortably fit MoE + spec
// decode batches (every layer's MoE op count is ~10-20 nodes).
inline constexpr std::size_t GRAPH_MAX_NODES = 1ull << 19;
inline constexpr std::size_t GRAPH_MAX_NODES = 1ull << 14; // reduced from 1<<19 (524288) to 1<<14 (16384) — the original value requests a ~10GB scheduler buffer regardless of model size, which fails malloc on memory-constrained systems for small models like Qwen3-0.6B.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'GRAPH_MAX_NODES' driver/portable -A3 -B3

Repository: pie-project/pie

Length of output: 5255


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== graph_common.hpp =="
cat -n driver/portable/src/graph_common.hpp | sed -n '1,120p'

echo
echo "== graph_qwen3.cpp around node estimates =="
sed -n '1,130p' driver/portable/src/graph_qwen3.cpp | cat -n

echo
echo "== graph_qwen3_5.cpp around node estimates =="
sed -n '330,430p' driver/portable/src/graph_qwen3_5.cpp | cat -n

echo
echo "== executor.cpp scheduler/init usage =="
sed -n '400,460p' driver/portable/src/executor/executor.cpp | cat -n
sed -n '1128,1155p' driver/portable/src/executor/executor.cpp | cat -n

Repository: pie-project/pie

Length of output: 24644


🌐 Web query:

ggml graph node capacity exceeded behavior ggml_new_graph_custom ggml_backend_sched_new graph max nodes

💡 Result:

When the node capacity of a ggml_cgraph is exceeded, the behavior depends on the specific implementation, but it typically results in memory corruption, silent failures, or crashes because ggml_cgraph structures are often backed by fixed-size buffers [1][2]. Key technical details regarding capacity and management include: 1. Understanding Capacity: The maximum number of nodes is governed by constants like GGML_DEFAULT_GRAPH_SIZE (typically 2048) [3][4][5]. When constructing graphs that exceed this, you must use ggml_new_graph_custom (or similar APIs that accept an n_nodes_max parameter) to explicitly allocate sufficient space [3][4]. 2. Backend-Specific Risks: Exceeding capacity can cause significant issues in specialized backends (such as CUDA) [1]. Some backends maintain their own internal ring buffers or metadata structures indexed by these limits (e.g., GGML_CUDA_MAX_NODES). If a graph exceeds the capacity managed by these internal buffers, the backend may silently overwrite data or trigger crashes at unpredictable points during execution, as it essentially runs out of "extra" slots for tensors [1]. 3. Scheduler Requirements: When using ggml_backend_sched_new, the graph_size parameter defines the capacity for the scheduler's internal management [5][6]. The scheduler uses this value to pre-allocate buffers [5]. If you attempt to compute a graph with a different topology or larger memory requirements than what was reserved, it can trigger reallocations, synchronization overhead, or error messages, though the scheduler is designed to handle common use cases through heuristics rather than complex dynamic optimization [5][7]. 4. Best Practices: To avoid capacity issues, it is recommended to: - Pre-calculate the required size using ggml_graph_overhead_custom before allocating the graph context [8]. - Use ggml_backend_sched_reserve with a "measure" graph that represents the maximum expected workload (e.g., maximum transformer context size) to ensure buffers are sized correctly for subsequent operations [5][7]. - If you frequently hit these limits, evaluate whether the computation graph can be simplified or modularized to reduce the total node count, rather than simply increasing the capacity constant [2].

Citations:


GRAPH_MAX_NODES is too small for supported large batches
graph_qwen3.cpp already estimates ~150k nodes for 512 requests over 28 layers, so 1 << 14 will under-size the graph budget on supported Qwen3 paths. Since the same constant also sizes the backend scheduler buffer, this risks graph-build/runtime failure on larger configs; keep a much larger cap or make the budget model/batch-specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@driver/portable/src/graph_common.hpp` around lines 22 - 24, GRAPH_MAX_NODES
is now too low for supported Qwen3 large-batch paths and can under-size both
graph construction and the backend scheduler buffer. Update the constant in
graph_common.hpp to preserve a much larger headroom, or refactor the budget to
be model/batch-specific using the existing graph_qwen3.cpp sizing logic so large
request counts do not fail at runtime.

Comment on lines +74 to +92
fn extract_final_answer(text: &str) -> Option<f64> {
let cleaned = strip_think_blocks(text);
let lower = cleaned.to_lowercase();
let marker = "answer:";
let idx = lower.rfind(marker)?;
let after = &cleaned[idx + marker.len()..];

let first_line = after.lines().next().unwrap_or("").trim();
let numeric: String = first_line
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
.collect();

if numeric.is_empty() {
None
} else {
numeric.parse::<f64>().ok()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Rust String::to_lowercase change the byte length of a string for some Unicode characters?

💡 Result:

Yes, Rust's String::to_lowercase method can change the byte length of a string [1]. Because Unicode case mapping is complex, some characters can expand into multiple characters when converted to lowercase [1][2]. Since Rust strings are UTF-8 encoded, and different characters require a varying number of bytes to store (from 1 to 4 bytes per Unicode scalar value), an increase in the number of characters—or a replacement of a character with one that has a different UTF-8 byte representation—will result in a change to the total byte length of the string [2][3]. For example, while case conversion typically maintains a 1:1 mapping, it is not guaranteed. This is why String::to_lowercase returns a new String rather than modifying the original one in-place, as the new string may require a different amount of memory [1]. If you need to perform case conversion without changing byte length or without allocating a new string, the standard library provides methods like str::to_ascii_lowercase, which is restricted to ASCII characters and preserves the byte length, as it only maps 'A'-'Z' to 'a'-'z' [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inferlets/graph-of-thought/src/lib.rs =="
sed -n '60,105p' inferlets/graph-of-thought/src/lib.rs

echo
echo "== inferlets/self-consistency/src/lib.rs =="
sed -n '60,105p' inferlets/self-consistency/src/lib.rs

echo
echo "== byte-length probe for Rust lowercasing edge cases =="
python3 - <<'PY'
samples = ["İ", "ẞ", "Σ", "ff", "A", "é", "ß"]
for s in samples:
    lower = s.lower()
    print(repr(s), len(s.encode()), "->", repr(lower), len(lower.encode()))
PY

Repository: pie-project/pie

Length of output: 3208


Use the lowercase buffer for the slice
idx comes from lower, but after is sliced from cleaned. to_lowercase() can change byte length for Unicode text, so this can hit the wrong boundary or panic; apply the same fix in inferlets/self-consistency/src/lib.rs as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/graph-of-thought/src/lib.rs` around lines 74 - 92,
`extract_final_answer` is slicing from `cleaned` using an index found in the
lowercased buffer, which can misalign for Unicode because `to_lowercase()` may
change byte length. Update `extract_final_answer` to derive the substring from
the same lowercase string used for `rfind`, or otherwise map the index safely
before slicing, and make the same correction in the matching
`extract_final_answer` implementation in
`inferlets/self-consistency/src/lib.rs`.

Comment on lines +94 to +112
/// Majority vote over extracted answers. Ties broken by first-occurrence
/// order among the tied values. Entries with no parseable answer are
/// excluded from voting entirely.
fn majority_vote(answers: &[Option<f64>]) -> Option<f64> {
let mut counts: Vec<(f64, usize)> = Vec::new();

for ans in answers.iter().flatten() {
if let Some(entry) = counts.iter_mut().find(|(v, _)| (*v - *ans).abs() < 1e-9) {
entry.1 += 1;
} else {
counts.push((*ans, 1));
}
}

counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(v, _)| v)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding code in inferlets/graph-of-thought/src/lib.rs
file="inferlets/graph-of-thought/src/lib.rs"
wc -l "$file"
sed -n '1,180p' "$file"

# Probe Rust tie behavior locally with a minimal equivalent example in Python.
python3 - <<'PY'
counts = [(1.0, 2), (2.0, 2), (3.0, 1)]
# Python's max would keep first; Rust's max_by_key keeps the last maximal element.
# Print the equivalent expected tie behavior for clarity.
print("max(counts, key=count) in Python returns first maximal element:", max(counts, key=lambda x: x[1]))
PY

Repository: pie-project/pie

Length of output: 6838


🌐 Web query:

Rust Iterator::max_by_key tie-breaking behavior last maximal element documentation

💡 Result:

In Rust, the Iterator::max_by_key method returns the last element that produces the maximum key when multiple elements are considered equal [1][2]. If the comparison determines that two or more elements are tied for the maximum value, the method effectively picks the one that appears latest in the iteration sequence [1][3]. It is important to distinguish this from the standalone std::cmp::max_by_key function, which compares only two values and returns the second argument if they are equal [4][5][6]. The Iterator method's behavior of returning the "last" maximal element is a result of its internal implementation, which keeps track of the "best" element found so far and updates it only when a strictly greater key is encountered [3].

Citations:


Tie-break doc is reversed. max_by_key keeps the last maximal entry, so tied answers currently resolve to last-occurrence order, not first-occurrence. Update the doc or make the tie-break explicit if first-seen order is required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/graph-of-thought/src/lib.rs` around lines 94 - 112, The tie-break
behavior in majority_vote is documented as first-occurrence, but max_by_key will
pick the last maximal entry among ties. Update majority_vote so the tie-break is
explicit and matches the intended first-seen order, or adjust the doc comment to
reflect the actual last-occurrence behavior; keep the change localized to
majority_vote and its comment.


[parameters]
question = {type = "string", optional = true, description = "The question to answer"}
proposal_tokens = {type = "string", optional = true, description = "Max tokens per proposal sample; array length = number of proposals (must be even)"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate parameter-type validation/enforcement logic in the runtime and bakery tooling
rg -n -C4 'ParameterType' runtime/src/program/manifest.rs
rg -n -C4 "manifest.get\\(.parameters.\\)|param_type|ParameterType" sdk/tools/bakery/src/bakery/inferlet.py

Repository: pie-project/pie

Length of output: 2222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Pie.toml ==\n'
cat -n inferlets/self-consistency/Pie.toml | sed -n '1,80p'

printf '\n== inferlets/self-consistency/src/lib.rs ==\n'
cat -n inferlets/self-consistency/src/lib.rs | sed -n '1,120p'

printf '\n== manifest schema/runtime validation ==\n'
rg -n -C4 'ParameterType|Parameter|parameters|validate' runtime/src/program/manifest.rs runtime/src/program -g '!**/target/**'

printf '\n== bakery inferlet param handling ==\n'
rg -n -C4 'param_type|parameters|type = "string"|type = "int"|type = "float"|type = "bool"|description' sdk/tools/bakery/src/bakery/inferlet.py sdk/tools/bakery -g '!**/target/**'

Repository: pie-project/pie

Length of output: 41035


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== manifest parsing / schema docs ==\n'
rg -n -C3 'type = "string"|type = "int"|type = "float"|type = "bool"|array|list|ParameterType|parameters' README.md docs runtime inferlets sdk/tools/bakery -g '!**/target/**'

printf '\n== inferlet macro / input payload path ==\n'
rg -n -C4 'serde_json|Deserialize|Input|parameters' inferlet inferlets runtime sdk -g '!**/target/**'

printf '\n== exact self-consistency use of proposal_tokens ==\n'
rg -n -C4 'proposal_tokens' inferlets/self-consistency/src/lib.rs inferlets/self-consistency/Pie.toml

Repository: pie-project/pie

Length of output: 50374


proposal_tokens needs a list-capable schema, not string. inferlets/self-consistency/Pie.toml declares a scalar field, but inferlets/self-consistency/src/lib.rs deserializes it as Vec<usize>. That makes manifest-driven tooling emit the wrong input shape for a value that must be a JSON array. Extend the manifest schema to support arrays, or change the inferlet to accept a scalar encoding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/self-consistency/Pie.toml` at line 13, The manifest schema for
proposal_tokens is declared as a scalar string, but
inferlets/self-consistency/src/lib.rs expects a Vec<usize>, so update the
Pie.toml declaration to a list-capable array schema for proposal_tokens or align
the inferlet deserialization to a scalar format. Use the proposal_tokens field
definition in Pie.toml and the corresponding parsing logic in the
self-consistency lib as the matching points, and make sure the manifest-driven
input shape matches the actual JSON array expected by the inferlet.

Comment on lines +67 to +85
fn extract_final_answer(text: &str) -> Option<f64> {
let cleaned = strip_think_blocks(text);
let lower = cleaned.to_lowercase();
let marker = "final answer:";
let idx = lower.rfind(marker)?;
let after = &cleaned[idx + marker.len()..];

let first_line = after.lines().next().unwrap_or("").trim();
let numeric: String = first_line
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
.collect();

if numeric.is_empty() {
None
} else {
numeric.parse::<f64>().ok()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant section.
git ls-files inferlets/self-consistency/src/lib.rs
wc -l inferlets/self-consistency/src/lib.rs
cat -n inferlets/self-consistency/src/lib.rs | sed -n '1,140p'

# Read-only probe: reproduce the byte-offset behavior of to_lowercase vs original string.
python3 - <<'PY'
samples = [
    "İ before final answer: 42",
    "abc FINAL ANSWER: 42",
    "foo final answer: 1",
    "xİy final answer: 3.14",
]

marker = "final answer:"
for s in samples:
    cleaned = s
    lower = cleaned.to_lowercase() if False else cleaned.lower()
    idx = lower.rfind(marker)
    print("\nINPUT:", repr(s))
    print("cleaned bytes:", len(cleaned.encode()))
    print("lower   bytes:", len(lower.encode()))
    print("idx from lower:", idx)
    if idx != -1:
        start = idx + len(marker)
        print("slice start:", start)
        try:
            print("cleaned slice:", cleaned[start:])
        except Exception as e:
            print("slice error:", type(e).__name__, e)
PY

Repository: pie-project/pie

Length of output: 6210


🏁 Script executed:

python3 - <<'PY'
def rust_like_slice_ok(original: str):
    cleaned = original
    lower = cleaned.lower()
    marker = "final answer:"
    idx = lower.encode("utf-8").find(marker.encode("ascii"))
    if idx < 0:
        return None

    start = idx + len(marker)
    b = cleaned.encode("utf-8")
    # valid UTF-8 char boundary positions in Rust are byte positions at codepoint starts or end
    boundaries = {0}
    i = 0
    while i < len(b):
        ch = b[i]
        if ch < 0x80:
            step = 1
        elif (ch & 0xE0) == 0xC0:
            step = 2
        elif (ch & 0xF0) == 0xE0:
            step = 3
        elif (ch & 0xF8) == 0xF0:
            step = 4
        else:
            raise ValueError("invalid utf-8")
        i += step
        boundaries.add(i)

    return {
        "cleaned_bytes": len(b),
        "lower_bytes": len(lower.encode("utf-8")),
        "idx": idx,
        "start": start,
        "is_boundary": start in boundaries,
        "slice": None if start > len(b) else b[start:].decode("utf-8", "replace"),
    }

samples = [
    "İ before final answer: 42",
    "xİy final answer: 3.14",
    "ß final answer: 7",
    "Hello final answer: 1",
    "A final answer: 2",  # fullwidth A; lower doesn't expand
]

for s in samples:
    print("\nINPUT:", repr(s))
    print(rust_like_slice_ok(s))
PY

Repository: pie-project/pie

Length of output: 813


🏁 Script executed:

python3 - <<'PY'
def rust_like_slice_ok(original: str):
    cleaned = original
    lower = cleaned.lower()
    marker = "final answer:"
    idx = lower.encode("utf-8").find(marker.encode("ascii"))
    if idx < 0:
        return None

    start = idx + len(marker)
    b = cleaned.encode("utf-8")
    boundaries = {0}
    i = 0
    while i < len(b):
        ch = b[i]
        if ch < 0x80:
            step = 1
        elif (ch & 0xE0) == 0xC0:
            step = 2
        elif (ch & 0xF0) == 0xE0:
            step = 3
        elif (ch & 0xF8) == 0xF0:
            step = 4
        else:
            raise ValueError("invalid utf-8")
        i += step
        boundaries.add(i)

    return {
        "cleaned_bytes": len(b),
        "lower_bytes": len(lower.encode("utf-8")),
        "idx": idx,
        "start": start,
        "is_boundary": start in boundaries,
        "slice": None if start > len(b) else b[start:].decode("utf-8", "replace"),
    }

samples = [
    "İ before final answer: 42",
    "xİy final answer: 3.14",
    "ß final answer: 7",
    "Hello final answer: 1",
    "A final answer: 2",
]

for s in samples:
    print("\nINPUT:", repr(s))
    print(rust_like_slice_ok(s))
PY

Repository: pie-project/pie

Length of output: 813


🏁 Script executed:

python3 - <<'PY'
# Search for a minimal counterexample where:
#   idx = lower.rfind("final answer:")
#   start = idx + len("final answer:")
# is not a valid slice start in the original cleaned string.
#
# We approximate Rust's Unicode lowercase behavior with Python's .lower(),
# then validate against UTF-8 byte boundaries.

marker = "final answer:"
candidates = ["İ", "ẞ", "ʼn", "Dž", "Σ", "Μ", "ϴ", "K", "ᾈ", "ᾼ"]

def byte_boundaries(s: str):
    b = s.encode("utf-8")
    out = {0}
    i = 0
    while i < len(b):
        c = b[i]
        if c < 0x80:
            step = 1
        elif (c & 0xE0) == 0xC0:
            step = 2
        elif (c & 0xF0) == 0xE0:
            step = 3
        else:
            step = 4
        i += step
        out.add(i)
    return len(b), out

for ch in candidates:
    for suffix in ["", " ", " 1", " 42"]:
        cleaned = f"{ch}{suffix}{marker}"
        lower = cleaned.lower()
        idx = lower.rfind(marker)
        start = idx + len(marker)
        clen, boundaries = byte_boundaries(cleaned)
        ok = start in boundaries and start <= clen
        print(repr(cleaned), "->", {"cleaned_bytes": clen, "lower_bytes": len(lower.encode()), "idx": idx, "start": start, "valid": ok})
PY

Repository: pie-project/pie

Length of output: 4173


🏁 Script executed:

cat <<'RS' >/tmp/check.rs
fn main() {
    let samples = [
        "İ before final answer: 42",
        "xİy final answer: 3.14",
        "ß final answer: 7",
        "Hello final answer: 1",
        "A final answer: 2",
    ];
    let marker = "final answer:";
    for s in samples {
        let lower = s.to_lowercase();
        let idx = lower.rfind(marker);
        println!("{:?}", s);
        println!("  cleaned bytes = {}", s.len());
        println!("  lower bytes   = {}", lower.len());
        println!("  idx           = {:?}", idx);
        if let Some(i) = idx {
            let start = i + marker.len();
            print!("  slice start   = {}", start);
            println!("  boundary?     = {}", s.is_char_boundary(start));
            if s.is_char_boundary(start) {
                println!("  tail          = {:?}", &s[start..]);
            }
        }
    }
}
RS

rustc /tmp/check.rs -O -o /tmp/check && /tmp/check

Repository: pie-project/pie

Length of output: 400


🏁 Script executed:

python3 - <<'PY'
marker = b"final answer:"

def utf8_boundaries(b):
    out = {0}
    i = 0
    while i < len(b):
        c = b[i]
        if c < 0x80:
            step = 1
        elif (c & 0xE0) == 0xC0:
            step = 2
        elif (c & 0xF0) == 0xE0:
            step = 3
        else:
            step = 4
        i += step
        out.add(i)
    return out

# Find characters whose lowercase mapping changes UTF-8 byte length.
hits = []
for cp in range(0x110000):
    ch = chr(cp)
    low = ch.lower()
    if len(low.encode("utf-8")) != len(ch.encode("utf-8")):
        hits.append((ch, ch.encode("utf-8"), low.encode("utf-8")))
        if len(hits) >= 20:
            break

print("length-changing lowercase samples:")
for ch, ob, lb in hits:
    print(repr(ch), len(ob), "->", len(lb), repr(ch.lower()))

print("\nCounterexample probes with marker after a single expanding char:")
for ch, ob, lb in hits[:20]:
    s = ch + "final answer:"
    b = s.encode("utf-8")
    lower_b = s.lower().encode("utf-8")
    idx = lower_b.rfind(marker)
    start = idx + len(marker)
    print(
        repr(s),
        {"orig_len": len(b), "lower_len": len(lower_b), "idx": idx, "start": start,
         "boundary": start in utf8_boundaries(b), "slice_ok": start <= len(b) and start in utf8_boundaries(b)}
    )
PY

Repository: pie-project/pie

Length of output: 2761


Avoid slicing cleaned with an offset from to_lowercase()
rfind() returns a byte index in the lowercased buffer, but Unicode lowercasing can change byte length before the marker, making cleaned[idx + marker.len()..] panic on some inputs. Use a byte-preserving ASCII lowercase/search or a case-insensitive search on the original string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inferlets/self-consistency/src/lib.rs` around lines 67 - 85,
`extract_final_answer` is slicing `cleaned` using an index computed from
`to_lowercase()`, which can become invalid for Unicode text; update the marker
search to use a byte-preserving approach or a case-insensitive search on the
original string, and then derive the `after` slice from the same source string
so `rfind`/slice offsets stay aligned.

- Add Generator::collect_text_with_tokens() to the SDK, returning exact
  token counts alongside decoded text (no functional change to existing
  collect_text()).
- Update self-consistency and graph-of-thought inferlets to use it,
  returning {"answer": ..., "tokens": N} instead of a bare string so
  token cost can be tracked per method.
- Add a single-shot baseline mode to benchmark_gsm8k.py (one proposal at
  the full matched token budget), reusing the self-consistency inferlet.
- Update parse_result()/summarize() to handle the new JSON format and
  report average tokens/question per method.

Results (N=50, 11 excluded due to WSL2/CUDA infra crashes, 39 valid):
- Baseline (single-shot): 23/39 correct (59.0%), avg ~1,007 tokens/question
- Self-consistency: 18/39 correct (46.2%), avg ~3,208 tokens/question
- Graph-of-Thought: 7/39 correct (17.9%), avg ~3,466 tokens/question

Baseline outperforms both multi-proposal methods while using roughly a
third of the tokens, on GSM8K at this model scale (Qwen3-0.6B).

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (2)
benchmark_gsm8k.py (1)

219-219: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead preds variable in summarize.

preds = [r for r in rows if ... or True] — the or True makes the condition always true, and preds is never used afterward. Remove it.

♻️ Proposed fix
 def summarize(key, label):
-    preds = [r for r in rows if r[f"{key}_pred"] is not None or r[f"{key}_tokens"] is not None or True]
     correct = sum(r[f"{key}_correct"] for r in rows)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_gsm8k.py` at line 219, The summarize logic has a dead preds
assignment because the filter condition is always true due to the trailing or
True, and the preds result is never used afterward. In summarize, remove the
pointless preds computation entirely, or if the rows need filtering, replace the
always-true condition with the intended predicate using key_pred and key_tokens
so the variable has a meaningful purpose.
benchmark_results.json (1)

1-652: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Recorded benchmark output committed as a data file.

This large auto-generated results snapshot (with sibling benchmark_results_sc_vs_got_only.json and benchmark_results_test_n3.json) is committed directly to the repo. Since benchmark_gsm8k.py writes to this same path incrementally on every run, future runs will produce diffs/merge noise here. Consider moving generated results under a gitignored output directory, or clearly documenting these as frozen point-in-time artifacts for the PR write-up rather than live state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmark_results.json` around lines 1 - 652, The benchmark results JSON is
being treated as a tracked source file even though `benchmark_gsm8k.py`
continuously rewrites it, which will cause noisy diffs on future runs. Move the
generated artifacts such as `benchmark_results.json`,
`benchmark_results_sc_vs_got_only.json`, and `benchmark_results_test_n3.json` to
a gitignored output location, or otherwise make it explicit in the
benchmark-writing code that these are frozen snapshots. Update the code path in
`benchmark_gsm8k.py` that emits the results so the repo does not accumulate
incremental benchmark output changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@benchmark_gsm8k.py`:
- Line 219: The summarize logic has a dead preds assignment because the filter
condition is always true due to the trailing or True, and the preds result is
never used afterward. In summarize, remove the pointless preds computation
entirely, or if the rows need filtering, replace the always-true condition with
the intended predicate using key_pred and key_tokens so the variable has a
meaningful purpose.

In `@benchmark_results.json`:
- Around line 1-652: The benchmark results JSON is being treated as a tracked
source file even though `benchmark_gsm8k.py` continuously rewrites it, which
will cause noisy diffs on future runs. Move the generated artifacts such as
`benchmark_results.json`, `benchmark_results_sc_vs_got_only.json`, and
`benchmark_results_test_n3.json` to a gitignored output location, or otherwise
make it explicit in the benchmark-writing code that these are frozen snapshots.
Update the code path in `benchmark_gsm8k.py` that emits the results so the repo
does not accumulate incremental benchmark output changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e7fc280-360f-4cf4-a036-1ecb1cf33778

📥 Commits

Reviewing files that changed from the base of the PR and between aa82fc8 and 23946de.

📒 Files selected for processing (7)
  • benchmark_gsm8k.py
  • benchmark_results.json
  • benchmark_results_sc_vs_got_only.json
  • benchmark_results_test_n3.json
  • inferlets/graph-of-thought/src/lib.rs
  • inferlets/self-consistency/src/lib.rs
  • sdk/rust/inferlet/src/generation.rs
✅ Files skipped from review due to trivial changes (2)
  • benchmark_results_sc_vs_got_only.json
  • benchmark_results_test_n3.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • inferlets/graph-of-thought/src/lib.rs

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants