pino-source-code / scripts /build_prospective_formulas.py
Matthew Ford
feat: prospective formula benchmark (40 in-silico formulas, blinded labels)
3fb95e8
Raw
History Blame Contribute Delete
6.48 kB
#!/usr/bin/env python3
"""Design the prospective formula benchmark (in-silico, preregistered).
Generates 30-50 deliberately-designed formulas from a restricted palette of
common hobbyist-available aroma chemicals (option held open for a future
community physical-compounding arm). Each formula spans a genre and a target
family profile with a top/heart/base substantivity structure.
Blinding: formulas and their *intended* target profiles are stored separately.
- data/benchmarks/prospective_formulas/formulas.jsonl : formula + structure (shareable)
- data/benchmarks/prospective_formulas/labels.sequestered.json : intended targets (EVAL ONLY)
The labels file carries access:"evaluation_only" and must not enter training or
model-selection inputs.
Deterministic: --seed fixes all sampling so the set is reproducible.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
from pathlib import Path
GENRES = {
"citrus_cologne": ["citrus_fresh", "aromatic_herbal", "green"],
"floral_woody": ["floral_sweet", "woody_amber", "musk_clean"],
"amber_oriental": ["gourmand", "woody_amber", "musk_clean"],
"fougere": ["aromatic_herbal", "green", "woody_amber"],
"wildcard": None, # any combination
}
# Substantivity bands (log10 hours-ish predicted) used to slot top/heart/base.
def band(sub):
if sub is None:
return "heart"
if sub < 1.0:
return "top"
if sub < 1.9:
return "heart"
return "base"
def load_palette(path: Path) -> dict[str, dict]:
return {k: v for k, v in json.loads(path.read_text()).items()}
def pick(seq, rng, n):
seq = list(seq)
rng.shuffle(seq)
return seq[:n]
def build_formula(fid, genre, palette, rng):
fams = GENRES[genre]
if fams is None:
fams = rng.sample(
["citrus_fresh", "floral_sweet", "woody_amber", "gourmand", "aromatic_herbal", "musk_clean", "green"],
k=rng.choice([2, 3]),
)
# Pool of materials across the chosen families, slotted by substantivity band.
by_band = {"top": [], "heart": [], "base": []}
for name, meta in palette.items():
if meta["family"] in fams:
by_band[band(meta.get("substantivity_log10"))].append((name, meta))
n_ing = rng.randint(6, 10)
n_top = max(1, round(n_ing * 0.3))
n_base = max(1, round(n_ing * 0.3))
n_heart = n_ing - n_top - n_base
chosen = []
chosen += pick(by_band["top"], rng, min(n_top, len(by_band["top"])))
chosen += pick(by_band["heart"], rng, min(n_heart, len(by_band["heart"])))
chosen += pick(by_band["base"], rng, min(n_base, len(by_band["base"])))
# Top up if a band ran dry.
if len(chosen) < n_ing:
rest = [(n, m) for b in by_band.values() for (n, m) in b if n not in {c[0] for c in chosen}]
chosen += pick(rest, rng, min(n_ing - len(chosen), len(rest)))
chosen = chosen[:n_ing]
# Dosing: base notes heavier, top notes lighter, with jitter; then normalise.
raw = []
for name, meta in chosen:
b = band(meta.get("substantivity_log10"))
w = {"top": rng.uniform(0.5, 2.0), "heart": rng.uniform(1.0, 4.0), "base": rng.uniform(2.0, 8.0)}[b]
raw.append((name, meta, w))
total = sum(w for _, _, w in raw)
ingredients = [
{
"name": meta["name"],
"cas": meta.get("cas"),
"smiles": meta.get("smiles"),
"family": meta["family"],
"weight_fraction": round(w / total, 4),
}
for (name, meta, w) in raw
]
# Intended target: weight-averaged family profile (the sequestered label).
fam_target: dict[str, float] = {}
for ing in ingredients:
fam_target[ing["family"]] = fam_target.get(ing["family"], 0.0) + ing["weight_fraction"]
fam_target = {k: round(v, 4) for k, v in sorted(fam_target.items())}
formula = {
"formula_id": fid,
"genre": genre,
"intended_families": fams,
"ingredients": ingredients,
"n_ingredients": len(ingredients),
"design": "in_silico_preregistered",
}
label = {
"formula_id": fid,
"target_family_profile": fam_target,
"access": "evaluation_only",
}
return formula, label
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--palette", default="artifacts/prospective/common_palette.json")
ap.add_argument("--out-dir", default="data/benchmarks/prospective_formulas")
ap.add_argument("--n", type=int, default=40)
ap.add_argument("--seed", type=int, default=20260717)
args = ap.parse_args()
palette = load_palette(Path(args.palette))
rng = random.Random(args.seed)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
genres = list(GENRES)
formulas, labels = [], []
for i in range(args.n):
genre = genres[i % len(genres)]
fid = f"PROSP_{args.seed}_{i:03d}"
f, l = build_formula(fid, genre, palette, rng)
formulas.append(f)
labels.append(l)
fpath = out_dir / "formulas.jsonl"
with fpath.open("w") as fh:
for f in formulas:
fh.write(json.dumps(f) + "\n")
lpath = out_dir / "labels.sequestered.json"
lpath.write_text(json.dumps({
"access": "evaluation_only",
"note": "Intended target family profiles. Forbidden in training and model selection.",
"seed": args.seed,
"labels": labels,
}, indent=1))
manifest = {
"benchmark": "prospective_formulas",
"created": "2026-07-17",
"seed": args.seed,
"n_formulas": len(formulas),
"genres": {g: sum(1 for f in formulas if f["genre"] == g) for g in genres},
"palette_size": len(palette),
"evaluation": "in_silico (model scoring/retrieval vs sequestered intended profiles)",
"community_arm": "option held open; palette restricted to hobbyist-available materials",
"label_access": "evaluation_only; forbidden in training and model selection",
"formula_sha256": hashlib.sha256(fpath.read_bytes()).hexdigest(),
"label_sha256": hashlib.sha256(lpath.read_bytes()).hexdigest(),
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=1))
print(f"wrote {len(formulas)} formulas -> {fpath}")
print(f"sequestered labels -> {lpath}")
print("genre counts:", manifest["genres"])
if __name__ == "__main__":
main()