Expand description
Formulation input domain model and draft generation for the generate
feature.
Roadmap (see docs/sdsforge-architecture.md’s “Generation architecture”
section):
- #8
input/validate—ProductInputand friends, deterministic offline validation. - #9
draft— maps a validatedProductInputto a partialSdsRoot(Section 1/3 only). - #10
provenance/unresolved/result— wraps #9’s draft in aGenerationResultthat explains what’s populated, where it came from, what’s still missing, and whether the draft is releasable. - #11+ chematic integration, CLI, GUI — not yet implemented.
Modules§
- field_
path - Canonical MHLW JSON path fragments, so every call site builds paths the same way instead of hand-formatting strings.
Structs§
- Component
Input - One ingredient of a product formulation.
- Concentration
Range - A single component’s concentration in a mixture.
- Evidence
Source - A reference to a piece of supporting evidence — a test report, a supplier specification, a database entry. Stores identifying metadata only (a reference string, issuer, date); never the document itself or arbitrary binary data.
- Evidence
Summary - Deterministic tally over a
GenerationResult’s provenance/unresolved records — never manually incremented elsewhere, always recomputed bycompute_evidence_summaryfrom the records themselves, so the counts can’t drift from what’s actually in the report. - Explosive
Limits Evidence - Explosive (flammability) limits need two bounds; otherwise the same
shape as
MeasuredValueEvidence, including the requiredconditionskey (see that struct’s doc comment). - Field
Policy - What evidence is even eligible for a given field, and whether missing it
blocks release. Data, not scattered conditionals — see
PRODUCT_LEVEL_POLICIESbelow. - Field
Provenance - A single field’s origin: where its value came from, how, and how
confident the generator is in it. One
FieldProvenanceper populated field. - Generation
Artifacts - The three artifacts a
generateinvocation publishes, as ready-to-write strings. Keeps filesystem concerns out ofsdsforge-corewhile giving Rust callers the same three outputs the CLI writes to disk. - Generation
Report - Everything needed to understand an SDS draft’s completeness, without the
draft itself. Never embeds
SdsRoot— seetests:: generation_report_json_does_not_contain_full_sds. - Generation
Result - The full generation report: the official SDS draft plus everything
needed to understand its incompleteness.
sdsis the only part that belongs inofficial_sds.json—findings/unresolved/provenance/evidence_summary/release_statusdescribe the draft, they are never written into it (seetests::official_sds_json_has_no_report_keys). - Measured
Properties Input - All measured-property evidence a caller supplies for the seven
properties
super::unresolved::PRODUCT_LEVEL_POLICIEScovers. Each field is aVec, not anOption— zero entries means nothing was supplied (staysUnresolved/HumanReviewRequired, unchanged from commit #10); more than one entry makes disagreement between reports representable, which a singleOptionslot could never express. - Measured
Value Evidence - A measured value for one of the four properties whose MHLW schema field
has a real numeric+unit shape (flash point, boiling point, vapor
pressure).
evidence_idmust match anEvidenceSource::idinsuper::ProductInput::evidence— a value with no resolvable evidence reference can never becomeConfirmed, onlySuppliedat best (see resolution logic insuper::result).conditionsdeliberately has no#[serde(default)]: it must remain a required key so an omitted measurement condition is a parse error, not a silently-accepted empty condition set. (MeasurementConditions’ own three fields are individuallyOption, soconditions: {}is valid — the acceptance policies insuper::resolveare what reject an insufficiently-specified condition set, not the parser.) - Measurement
Conditions - Measurement conditions for a physical/chemical property. Kept small — this only needs enough structure to say what evidence the user must provide, not to model a general laboratory information system.
- NotApplicable
Reason - Placeholder for a deliberate “this field does not apply to this product”
determination. Not constructed anywhere in this commit’s generation
logic — see
docs/sdsforge-architecture.md’s generation-architecture section: when applicability genuinely can’t be determined (e.g. this commit’sProductInputcarries no physical-state field), the correct reason isUnresolvedReason::HumanReviewRequired, not a guessedNotApplicable. Defined now sosuper::FieldStatuscompiles and so a future commit that can determine applicability has a type to use. - Product
Input - Raw input to the
generatefeature: a product’s identity, supplier, formulation, and any measured-property evidence. This is the domain model — deliberately separate from the MHLW-schemaSdsRootincrate::schema, which is the output shape. - Release
Gate Result - Required
Input - One piece of evidence a human would need to supply to resolve a field. Kept small — enough to explain what’s needed, not a full lab-data model.
- Section
Draft Result - A partial SDS draft covering only Section 1 (Identification) and
Section 3 (Composition) — the sections derivable from supplied product/
supplier/CAS/concentration data without product testing. Every other
SdsRootfield is leftNone. - Supplier
Input - Supplier contact details for Section 1 (Identification).
- Test
Result Evidence - For the three properties whose MHLW schema field is free text with no
structured numeric value at all (self-reactivity, oxidizing properties,
metal corrosivity) —
resultdescribes the test outcome directly rather than a value+unit pair, since that’s the actual shape of the target field. - Unresolved
Field - A field the generator could not populate, with enough detail for a human (or a downstream system) to know what’s missing, why, how bad it is to ship without it, and what evidence would resolve it.
Enums§
- Confidence
Level - How confident the generator is in a value, independent of where the
value came from. A
SupplierSpecificationsource and aProductTestReportsource can both beHighconfidence; anUnverifiedUserInputsource isUnverifiedby construction — nothing about passing structural validation (CAS check-digit, concentration bounds) upgrades it, since syntactic validity isn’t evidence that a company name, address, or product identity is factually correct. - Evidence
Applicability - What the evidence actually was measured on — a mixture property can
legitimately be confirmed by finished-product or same/equivalent-batch
evidence, but never by component-level or reference-substance data (see
super::result’s evidence-resolution logic). - Evidence
Level - Priority-ordered evidence source. Not every level is valid for every
field — see
super::unresolved::FieldPolicy::allowed_evidence. - Field
Status - How a field’s value was determined.
Calculated/Estimated/Literaturemust never auto-promote toConfirmed— promotion requires new evidence, not a higher-confidence label on the same evidence. - Generation
Artifact Error - Regulatory
Impact - Release
Status - Generation must never mark its own output approved — approval is a
separate human act (approver, timestamp, target version), never a side
effect of running the generator.
Draft/ReviewRequired/Blockedare the only valuescompute_release_statuscan produce;Approvedexists on the enum for a future explicit human-approval record to set, not for generation code to reach (seetests::generation_never_approves). - Safety
Impact - Unresolved
Reason - Why a value couldn’t be filled in.
Constants§
- MOLECULAR_
FORMULA_ POLICY - Molecular formula as returned by
crate::enrichment::lookup_cas: an identity candidate, reference-database evidence, no product test required. Included for completeness of the policy table — commit #9 already populates this field when the resolver supplies it; this policy documents the evidence rule that governs it. - PRODUCT_
LEVEL_ POLICIES - Product-level physical/hazard properties that Commit #9’s Section 1/3
draft never populates — CAS identity and composition alone are not
sufficient evidence for any of these (see
docs/sdsforge-architecture.md’s “Properties that require product-level evidence” table). Every one of these becomes anUnresolvedFieldon every generation result, sinceProductInput(commit #8) supplies none of the underlying test data. - REPORT_
SCHEMA_ VERSION - Version of the
generation_report.jsonshape, independent of the crate version — the report format and the crate can each change without the other. No timestamp is embedded by default: repeated serialization of the sameGenerationResultmust stay byte-equivalent.
Functions§
- build_
generation_ artifacts - build_
generation_ report - Builds a
GenerationReportfrom aGenerationResult. - build_
product_ level_ unresolved - Builds the always-present product-level
UnresolvedFields. - compute_
evidence_ summary - Recomputes the summary from scratch every time — see
EvidenceSummary’s doc comment for why this is deliberate. - compute_
release_ status Blockedif anything explicitly blocks release (an unresolved field markedblocks_release, or a CRIT/HIGH validation finding); otherwiseReviewRequiredif anything at all remains unresolved or was merely flagged;Draftonly when there’s truly nothing to review. Given this feature only ever produces Section 1/3 and always adds the seven product-level unresolved fields, real results areBlockedorReviewRequired—Draftis reachable but not the common case, exactly as intended: an incomplete draft should not look deceptively finished.- draft_
sections_ from_ resolved_ input - Pure, deterministic mapping from
ProductInputto a Section 1/3 draft. - evaluate_
release_ gate - Aggregates a
GenerationResultinto the CRIT/HIGH findings and required actions that actually block release — deterministic, built fromresult.unresolved/result.findingsonly, never a second source of truth.required_actionsis deduplicated by exact string match (sufficient for now — multiple unresolved fields commonly share the same recommended action, e.g. two properties both needing “a human must first determine whether this property applies”). - generate_
from_ detailed_ lookups - Pure core of
generate_with_detailed_enrichment— no network access, so it’s fully unit-testable with a fixturelookupsmap (seetests::lookup_error_is_nonblocking_and_retains_supplied_data). - generate_
from_ normalized_ input - Adds chematic-backed chemical-identity normalization on top of
generate_from_resolved_input— reuses it rather than duplicating Section 1/3 mapping logic. Strategy: derive a plainHashMap<String, CasInfo>containing only genuinelyCasResolution::Resolved(non- ambiguous) candidates and call the existing, unchangedgenerate_from_resolved_input— an ambiguous CAS is simply absent from that map, so the base pass treats it exactly like today’s “lookup didn’t resolve” case (commit #9’sGEN-CAS-ENRICHMENT-MISSING/MissingInput, unchanged). This function then does one additive pass over the already-built result: replaces the generic lookup-failure unresolved entry with a specificAmbiguousChemicalIdentityone for ambiguous components, and for resolved components runs the normalizer and layers on canonical-SMILES writing / formula-consistency handling. - generate_
from_ resolved_ input - Pure, deterministic: wraps
draft_sections_from_resolved_input(does not duplicate its mapping logic) and adds provenance/unresolved/ evidence-summary/release-status on top. Sameresolvedshape as commit #9’s function — aHashMapof successful CAS lookups only, so a missing entry cannot be distinguished as “not found” vs. “request failed” here either; see [build_lookup_failure_unresolved]’s doc comment for why that’s a recorded limitation, not a redesign target for this commit. - generate_
section_ 1_ and_ 3 - Orchestration: performs CAS enrichment lookups (network I/O against
PubChem via
crate::enrichment::lookup_cas— no second PubChem client or CAS resolver is implemented here), then delegates all mapping and finding logic todraft_sections_from_resolved_input. - generate_
with_ detailed_ enrichment - Orchestration for the
sdsforge generate --enrichCLI path: resolves every distinct CAS number ininputthroughlookup_cas_detailed(deduplicated — one request per distinct CAS, not per component), then delegates everything else togenerate_from_detailed_lookups. A network/HTTP/parse failure for one CAS never aborts the whole draft — it’s recorded as aResult::Errand surfaces as aGEN-CAS-LOOKUP-ERRORfinding, while every other component’s lookup still proceeds normally. - generate_
with_ enrichment - Orchestration: performs the CAS lookups (reusing
crate::enrichment::lookup_cas, no second PubChem client), then delegates everything else togenerate_from_resolved_input. This is the one new orchestration path this commit adds — it does not call commit #9’sgenerate_section_1_and_3, which would re-fetch the same CAS data over the network a second time for no benefit. - render_
review_ report - Renders a deterministic, human-readable Markdown summary of a
GenerationReport. Never uses an LLM. Always states plainly that the output is an unapproved automated draft — even a (currently unreachable-by-generation-code)ReleaseStatus::Approvedstill carries that disclaimer, so the wording can never be read as a real approval. - serialize_
generation_ report - Serializes a
GenerationReportas pretty JSON. Contains only whatGenerationReportitself carries — no API keys, HTTP headers, evidence binaries, temp-file paths, or ad hoc debug formatting ever enter this type, so there is nothing to redact here. - serialize_
official_ sds - Serializes only
result.sds— the same serialize→prune_empty_fields→ pretty-print path the existingto-json/rendercommands already use, soofficial_sds.jsonmatches the formatting of every other JSON this crate writes. No report metadata, evidence references, or normalization diagnostics are ever added. - validate_
product_ input - Deterministic, offline validation of a
ProductInputbefore anySdsRootfragment is generated from it. Checks only what’s derivable from the raw input itself: CAS format/check-digit, duplicate CAS across components, and per-component/aggregate concentration sanity.