use gugen::{
Composition, Element, InMemoryLiteratureConditionProvider, InMemoryPrecursorCatalog, Planner,
PlanningConfig, PlanningConstraints, PrecursorCandidate, PrecursorId, ProcessStep,
RejectionCode, SynthesisPlanningReport, TargetSpecification,
};
use std::collections::{BTreeMap, BTreeSet};
use std::time::Instant;
const CORPUS_JSONL: &str = include_str!("../benchmarks/data/kononova_sample.jsonl");
const DECOY_POOL_SIZE: usize = 60;
const MAX_DECOYS_PER_ROW: usize = 8;
#[derive(serde::Deserialize)]
struct CorpusPrecursor {
formula: String,
elements: BTreeMap<String, f64>,
}
#[derive(serde::Deserialize)]
struct CorpusRow {
doi: Option<String>,
target_formula: Option<String>,
target_elements: BTreeMap<String, f64>,
precursors: Vec<CorpusPrecursor>,
}
fn load_corpus() -> Vec<CorpusRow> {
CORPUS_JSONL
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
serde_json::from_str(l).expect(
"benchmarks/data/kononova_sample.jsonl must be valid JSONL -- \
regenerate with benchmarks/fetch_kononova.py",
)
})
.collect()
}
fn try_composition(elements: &BTreeMap<String, f64>) -> Option<Composition> {
let pairs: Option<Vec<(Element, f64)>> = elements
.iter()
.map(|(sym, amt)| Element::new(sym).ok().map(|e| (e, *amt)))
.collect();
Composition::new(pairs?).ok()
}
struct ParsedRow {
doi: String,
target_formula: String,
target: Composition,
precursors: Vec<PrecursorCandidate>,
}
fn parse_rows(raw: Vec<CorpusRow>) -> (Vec<ParsedRow>, usize) {
let mut parsed = Vec::new();
let mut unparseable = 0;
for row in raw {
let Some(target) = try_composition(&row.target_elements) else {
unparseable += 1;
continue;
};
let mut precursors = Vec::new();
let mut ok = true;
for p in &row.precursors {
let Some(composition) = try_composition(&p.elements) else {
ok = false;
break;
};
precursors.push(PrecursorCandidate {
id: PrecursorId(p.formula.clone()),
composition,
availability: None,
});
}
if !ok || precursors.is_empty() {
unparseable += 1;
continue;
}
parsed.push(ParsedRow {
doi: row.doi.unwrap_or_default(),
target_formula: row.target_formula.unwrap_or_default(),
target,
precursors,
});
}
(parsed, unparseable)
}
fn phase10_targets() -> Vec<Composition> {
fn c(pairs: &[(&str, f64)]) -> Composition {
Composition::new(pairs.iter().map(|&(s, a)| (Element::new(s).unwrap(), a))).unwrap()
}
vec![
c(&[("La", 1.0), ("Al", 1.0), ("O", 3.0)]),
c(&[("Mg", 1.0), ("Al", 2.0), ("O", 4.0)]),
c(&[("Ca", 1.0), ("O", 1.0)]),
c(&[("Zn", 3.0), ("P", 2.0), ("O", 8.0)]),
c(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]),
]
}
fn decoy_pool(rows: &[ParsedRow]) -> Vec<PrecursorCandidate> {
let mut counts: BTreeMap<String, (usize, Composition)> = BTreeMap::new();
for row in rows {
for p in &row.precursors {
counts
.entry(p.id.0.clone())
.and_modify(|(n, _)| *n += 1)
.or_insert((1, p.composition.clone()));
}
}
let mut ranked: Vec<(String, usize, Composition)> =
counts.into_iter().map(|(f, (n, c))| (f, n, c)).collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
ranked
.into_iter()
.take(DECOY_POOL_SIZE)
.map(|(formula, _, composition)| PrecursorCandidate {
id: PrecursorId(formula),
composition,
availability: None,
})
.collect()
}
fn catalog_for(row: &ParsedRow, pool: &[PrecursorCandidate]) -> Vec<PrecursorCandidate> {
let true_ids: BTreeSet<&str> = row.precursors.iter().map(|p| p.id.0.as_str()).collect();
let target_elements: BTreeSet<Element> = row.target.elements().collect();
let mut candidates = row.precursors.clone();
candidates.extend(
pool.iter()
.filter(|d| !true_ids.contains(d.id.0.as_str()))
.filter(|d| {
d.composition
.elements()
.any(|e| target_elements.contains(&e))
})
.take(MAX_DECOYS_PER_ROW)
.cloned(),
);
candidates
}
fn plan_row(row: &ParsedRow, pool: &[PrecursorCandidate]) -> SynthesisPlanningReport {
let catalog = InMemoryPrecursorCatalog::new(catalog_for(row, pool));
let target_spec = TargetSpecification {
composition: row.target.clone(),
structure: None,
desired_phase: None,
constraints: PlanningConstraints::default(),
};
Planner::with_process_evidence_provider(
catalog,
InMemoryLiteratureConditionProvider::from_curated_records(),
PlanningConfig::default(),
)
.plan(&target_spec, "2026-08-14T00:00:00Z")
.unwrap_or_else(|e| {
panic!(
"planning must not fail for a pre-validated row ({}, DOI {}): {e}",
row.target_formula, row.doi
)
})
}
fn is_element_balanced(plan: &gugen::SynthesisPlan) -> bool {
let Some(reaction) = &plan.balanced_reaction else {
return false;
};
let mut lhs: BTreeMap<Element, f64> = BTreeMap::new();
let mut rhs: BTreeMap<Element, f64> = BTreeMap::new();
for species in &reaction.reactants {
for (el, amt) in species.composition.iter() {
*lhs.entry(el).or_insert(0.0) += amt * species.coefficient as f64;
}
}
for species in &reaction.products {
for (el, amt) in species.composition.iter() {
*rhs.entry(el).or_insert(0.0) += amt * species.coefficient as f64;
}
}
lhs.len() == rhs.len()
&& lhs
.iter()
.all(|(el, amt)| (rhs.get(el).copied().unwrap_or(f64::NAN) - amt).abs() < 1e-9)
}
fn pct(count: usize, total: usize) -> f64 {
if total == 0 {
0.0
} else {
100.0 * count as f64 / total as f64
}
}
fn step_variant_name(step: &ProcessStep) -> &'static str {
match step {
ProcessStep::Weigh { .. } => "Weigh",
ProcessStep::Mix { .. } => "Mix",
ProcessStep::Grind { .. } => "Grind",
ProcessStep::Form { .. } => "Form",
ProcessStep::Heat { .. } => "Heat",
ProcessStep::Cool { .. } => "Cool",
ProcessStep::IntermediateCharacterization { .. } => "IntermediateCharacterization",
}
}
fn main() {
let raw = load_corpus();
let raw_count = raw.len();
let (rows, unparseable) = parse_rows(raw);
let pool = decoy_pool(&rows);
let phase10 = phase10_targets();
let start = Instant::now();
let reports: Vec<SynthesisPlanningReport> = rows.iter().map(|r| plan_row(r, &pool)).collect();
let plan_elapsed = start.elapsed();
let all_plans: Vec<&gugen::SynthesisPlan> =
reports.iter().flat_map(|r| r.plans.iter()).collect();
let mut out = String::new();
out.push_str("# gugen v0.2.0 large-scale blind benchmark report (Phase 11)\n\n");
out.push_str(&format!(
"Generated by `cargo run --example large_scale_benchmark --features serde` \
(AGENTS.md §22/§23). Measured against \
`benchmarks/data/kononova_sample.jsonl`, a {raw_count}-reaction holdout sample of the \
Kononova et al. 2019 corpus (CC BY 4.0) with every route already used by \
`tests/validation.rs`'s 5 fixtures or `src/literature_conditions.rs`'s Phase 10 curated \
records excluded -- see `benchmarks/data/ATTRIBUTION.md` for the exact filter counts \
and `benchmarks/fetch_kononova.py` for the exclusion mechanism. Re-run this example and \
replace this file's content after any change to `score.rs`, `planner.rs`, or the fetch \
script's filter criteria, rather than hand-editing numbers here.\n\n",
));
out.push_str(&format!(
"- **Corpus loading:** {raw_count} rows loaded; {unparseable} could not be represented \
by gugen's own types (`Element::new`/`Composition::new` failure) despite \
`fetch_kononova.py`'s own filtering -- expected near-zero, and is: a non-zero count \
here would mean that Python-side filter has a gap the Rust side has to defend against \
(AGENTS.md §25, never panic on ordinary input).\n",
));
let with_plans = reports.iter().filter(|r| !r.plans.is_empty()).count();
out.push_str(&format!(
"- **Valid reaction generation rate:** {with_plans}/{} holdout rows produced at least \
one plan.\n",
rows.len()
));
let balanced = all_plans.iter().filter(|p| is_element_balanced(p)).count();
out.push_str(&format!(
"- **Element-balance exactness:** {balanced}/{} produced plans conserve every element \
exactly (re-verified against the plan's own reaction, not assumed from `balance()`'s \
design alone).\n",
all_plans.len()
));
let exact_recovered = rows
.iter()
.zip(&reports)
.filter(|(row, report)| {
let true_ids: BTreeSet<&str> = row.precursors.iter().map(|p| p.id.0.as_str()).collect();
report.plans.iter().any(|p| {
let ids: BTreeSet<&str> = p
.precursors
.iter()
.map(|s| s.precursor.0.as_str())
.collect();
ids == true_ids
})
})
.count();
out.push_str(&format!(
"- **Known precursor-set exact recovery:** {exact_recovered}/{} holdout rows' cited \
route was recovered exactly by at least one produced plan (anywhere in the ranked \
list).\n",
rows.len()
));
let with_alternative = rows
.iter()
.zip(&reports)
.filter(|(row, report)| {
let true_ids: BTreeSet<&str> = row.precursors.iter().map(|p| p.id.0.as_str()).collect();
report.plans.iter().any(|p| {
let ids: BTreeSet<&str> = p
.precursors
.iter()
.map(|s| s.precursor.0.as_str())
.collect();
ids != true_ids
})
})
.count();
out.push_str(&format!(
"- **Partial precursor match (valid alternative beyond the cited route, found via the \
decoy pool):** {with_alternative}/{} holdout rows -- not errors, real chemically valid \
alternatives the decoy-augmented catalog happens to also support (same framing as \
`tests/validation.rs`'s own La2(CO3)3 precedent).\n",
rows.len()
));
let all_route_families = [
gugen::RouteFamily::ConventionalSolidState,
gugen::RouteFamily::Mechanochemical,
];
let seen_route_families: BTreeSet<gugen::RouteFamily> =
all_plans.iter().map(|p| p.route_family).collect();
out.push_str(&format!(
"- **Route-family coverage:** {}/{} route families exercised across this corpus \
(Phase 12 added `Mechanochemical` alongside `ConventionalSolidState`; both are offered \
unconditionally for every accepted precursor set, AGENTS.md §13, so this is expected to \
be 100% for any corpus with at least one row that produces a plan, not a \
discriminator).\n",
seen_route_families.len(),
all_route_families.len()
));
let mut seen_steps = BTreeSet::new();
for plan in &all_plans {
for planned in &plan.steps {
seen_steps.insert(step_variant_name(&planned.step));
}
}
let all_step_kinds = [
"Weigh",
"Mix",
"Grind",
"Form",
"Heat",
"Cool",
"IntermediateCharacterization",
];
out.push_str(&format!(
"- **Process-step coverage:** {}/{} step kinds exercised across {} produced plans: \
{seen_steps:?}.\n",
seen_steps.len(),
all_step_kinds.len(),
all_plans.len()
));
let (phase10_rows, other_rows): (Vec<_>, Vec<_>) = rows
.iter()
.zip(&reports)
.partition(|(row, _)| phase10.iter().any(|t| t == &row.target));
let coverage = |group: &[(&ParsedRow, &SynthesisPlanningReport)]| {
let plans: Vec<&gugen::SynthesisPlan> =
group.iter().flat_map(|(_, r)| r.plans.iter()).collect();
let resolved = plans
.iter()
.filter(|p| p.confidence.process_conditions.value() > 0.0)
.count();
(resolved, plans.len())
};
let (p10_resolved, p10_total) = coverage(&phase10_rows);
let (rest_resolved, rest_total) = coverage(&other_rows);
out.push_str(&format!(
"- **Condition evidence coverage, split by Phase 10 target overlap:** \
{p10_resolved}/{p10_total} plans resolved a condition among the {} holdout rows whose \
*target* matches one of Phase 10's 5 curated targets (via a *different* precursor route \
than the curated record -- `InMemoryLiteratureConditionProvider` matches on target \
composition alone, so these resolve `EvidenceScope::SimilarMaterial`, not \
`ExactTarget`; this is not evidence gugen predicts conditions for unseen targets, only \
that it correctly reuses a curated record across a different route to the *same* known \
target). {rest_resolved}/{rest_total} resolved among the remaining {} rows -- expected \
near-zero, confirming Phase 10's coverage has not accidentally generalized beyond its 5 \
curated targets.\n",
phase10_rows.len(),
other_rows.len()
));
let total_unresolved: usize = all_plans.iter().map(|p| p.unresolved.len()).sum();
out.push_str(&format!(
"- **Unresolved condition rate:** {total_unresolved} total unresolved condition entries \
across {} plans ({:.2} per plan on average).\n",
all_plans.len(),
total_unresolved as f64 / all_plans.len().max(1) as f64
));
let confidences: BTreeSet<u64> = all_plans
.iter()
.map(|p| p.confidence.overall.value().to_bits())
.collect();
out.push_str(&format!(
"- **False confident plan rate:** {} distinct `confidence.overall` value(s) observed \
across {} plans (contrast `examples/benchmark_report.rs`'s small fixture set, which \
sees exactly 1 -- this corpus's mix of Phase-10-resolved and unresolved rows gives real \
variability, though still not yet a validated correctness signal).\n",
confidences.len(),
all_plans.len()
));
let mut code_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut total_rejected = 0usize;
for report in &reports {
for r in &report.rejected_candidates {
total_rejected += 1;
for code in &r.reason_codes {
*code_counts.entry(format!("{code:?}")).or_insert(0) += 1;
}
}
}
let byproduct_count = code_counts
.get(&format!(
"{:?}",
RejectionCode::UnsupportedByproductRequired
))
.copied()
.unwrap_or(0);
let missing_element_count = code_counts
.get(&format!("{:?}", RejectionCode::MissingTargetElement))
.copied()
.unwrap_or(0);
let no_balance_count = code_counts
.get(&format!("{:?}", RejectionCode::NoStoichiometricBalance))
.copied()
.unwrap_or(0);
let duplicate_count = code_counts
.get(&format!("{:?}", RejectionCode::DuplicatePlan))
.copied()
.unwrap_or(0);
let coverage_passing = byproduct_count + no_balance_count + duplicate_count + all_plans.len();
out.push_str(&format!(
"- **Rejected-candidate reason stratification:** {total_rejected} rejected candidates \
across the sample. `RejectionCode` counts: {code_counts:?}. \
`MissingTargetElement` dominates by a wide margin ({missing_element_count}/{total_rejected}, \
{:.1}%) -- not a chemistry finding but a mechanical one: `search_precursor_sets` checks \
element coverage before any byproduct/balance check, and this corpus's decoy-augmented \
catalogs (mean ~10 candidates/row, see the search-budget line below) generate many small \
subsets that simply don't happen to cover one specific target's element set. \
`UnsupportedByproductRequired`'s share of *all* rejections is {byproduct_count}/{total_rejected} \
({:.1}%), but the more meaningful figure is its share among the \
{coverage_passing} combinations that *did* pass the element-coverage gate (byproduct + \
no-balance + duplicate + accepted-and-kept): {byproduct_count}/{coverage_passing} \
({:.1}%). This isolates genuine byproduct-allow-list gaps from decoy-driven coverage \
noise -- nitrate/acetate/oxalate/chloride precursors (common in this corpus) release \
byproduct species outside gugen's curated allow-list (CO2/H2O/O2 only, \
`src/balance.rs::curated_byproducts`). Not widened reactively in response to this \
number -- doing so without independent literature grounding per target would be exactly \
the benchmark-driven overfitting AGENTS.md §27 forbids.\n",
pct(missing_element_count, total_rejected),
pct(byproduct_count, total_rejected),
pct(byproduct_count, coverage_passing),
));
let exhausted = reports
.iter()
.filter(|r| {
r.rejected_candidates
.iter()
.any(|c| c.reason_codes == vec![RejectionCode::SearchBudgetExhausted])
})
.count();
let catalog_sizes: Vec<usize> = rows.iter().map(|r| catalog_for(r, &pool).len()).collect();
let mean_catalog_size =
catalog_sizes.iter().sum::<usize>() as f64 / catalog_sizes.len().max(1) as f64;
out.push_str(&format!(
"- **Search-budget exhaustion rate:** {exhausted}/{} rows hit \
`RejectionCode::SearchBudgetExhausted` against `SearchBudget::default()` \
(`max_precursor_sets: 10_000`), with up to {MAX_DECOYS_PER_ROW} element-overlapping \
decoys added per row (mean catalog size {mean_catalog_size:.1} candidates per row, \
pool of the {DECOY_POOL_SIZE} globally most frequent precursor formulas in this \
sample). This decoy cap was chosen by measuring this exact rate at several candidate \
values and picking the largest that kept it negligible.\n",
rows.len()
));
let start = Instant::now();
let rerun: Vec<SynthesisPlanningReport> = rows.iter().map(|r| plan_row(r, &pool)).collect();
let rerun_elapsed = start.elapsed();
let reproducible = reports == rerun;
eprintln!(
"reproducibility check: first pass {:.2}s, second pass {:.2}s",
plan_elapsed.as_secs_f64(),
rerun_elapsed.as_secs_f64()
);
out.push_str(&format!(
"- **Deterministic reproducibility:** {} -- replanning the entire {}-row sample a \
second time produced byte-for-byte identical reports.\n",
if reproducible {
"yes"
} else {
"NO -- regression, investigate immediately"
},
rows.len(),
));
let per_plan_us = plan_elapsed.as_micros() as f64 / rows.len().max(1) as f64;
let throughput_bound = if per_plan_us < 1_000.0 {
"well under 1 millisecond"
} else if per_plan_us < 10_000.0 {
"under 10 milliseconds"
} else {
"10 milliseconds or more -- regression, investigate"
};
eprintln!("planning throughput: ~{per_plan_us:.0} microseconds per Planner::plan call");
out.push_str(&format!(
"- **Planning throughput:** {throughput_bound} per `Planner::plan` call, averaged over \
{} calls against this sample's real (decoy-augmented) catalogs on the machine that \
generated this report. The raw microsecond figure is printed to stderr, not into this \
checked-in report, since it varies run to run and would make this file diff against \
itself on every regeneration.\n",
rows.len()
));
out.push_str("\n## Skipped, not silently\n\n");
out.push_str(
"- **§23 differential validation** against another synthesis-planning implementation: \
not attempted, same reasoning as `examples/benchmark_report.rs` -- §23 says 可能なら \
(\"if possible\"), no runnable reference implementation exists in this workspace.\n",
);
out.push_str(
"- **§22 temperature-specific metrics** (predicted-range-contains-reference rate, \
unsupported-exact-value rate): still undefined, not zero. This corpus's own reported \
temperatures are deliberately *not* embedded in `kononova_sample.jsonl` (see \
`benchmarks/fetch_kononova.py`) precisely to avoid the temptation to score gugen's \
`None` predictions against them, which would not be a meaningful MAE.\n",
);
out.push_str(
"- **Out-of-domain abstention rate / arithmetic overflow handling:** covered by \
`examples/benchmark_report.rs`'s dedicated adversarial cases, not duplicated here -- \
every row in this corpus is, by construction, a parseable in-domain composition (that's \
what `benchmarks/fetch_kononova.py`'s filter selects for), so this corpus cannot itself \
exercise either path.\n",
);
print!("{out}");
}