pino-source-code / scripts /convert_wisemoor_to_training_candidates.py
Matthew Ford
feat: low-evidence gating in substitution_v11 + wisemoor description provenance
cc86172
Raw
History Blame Contribute Delete
31.7 kB
#!/usr/bin/env python3
"""Convert sanitized WiseMoor formulas into PINO training candidates.
This is intentionally gated: sanitized WiseMoor rows are name/amount formulas,
while the core PINO training set needs CAS-resolved, normalized formulas. The
script writes only formulas that meet configurable resolution thresholds and
also writes an audit file for the unresolved materials.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import unicodedata
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from ingest_tgsc import build_tgsc_mapping, map_ingredient # noqa: E402
from pino.registry import AromaRegistry # noqa: E402
WISEMOOR_NAME_OVERRIDES: dict[str, str] = {
# Solvents / carriers.
"dep": "84-66-2", # diethyl phthalate
"d.p.g": "25265-71-8",
"dpg": "25265-71-8",
"dipropylene glycol": "25265-71-8",
"t.e.c": "77-93-0", # triethyl citrate
"i.p.m": "110-27-0", # isopropyl myristate
# WiseMoor spelling/order variants for common aroma chemicals.
"acetate benzyl": "140-11-4",
"acetate bornyl": "76-49-3",
"acetate bornyl l": "76-49-3",
"acetate butyl": "123-86-4",
"acetate citronellyl": "150-84-5",
"acetate d.m.b.c": "151-05-3",
"dmbc acetate": "151-05-3",
"acetate ethyl": "141-78-6",
"acetate geranyl": "105-87-3",
"acetate hexenyl cis-3": "3681-71-8",
"acetate hexyl": "142-92-7",
"acetate isoamyl": "123-92-2",
"acetate amyl": "628-63-7",
"amyl acetate": "628-63-7",
"acetate cinnamic": "103-54-8",
"acetate linalyl": "115-95-7",
"acetate linallyl": "115-95-7",
"acetate neryl": "141-12-8",
"acetate octyl": "112-14-1",
"acetate prenyl": "1191-16-8",
"acetate styrallyl": "93-92-5",
"acetate styralyl": "93-92-5",
"acetate terpenyl": "80-26-2",
"acetate terpene": "80-26-2",
"acetophenone": "98-86-2",
"aldehyde alpha hexyl cinnamic": "101-86-0",
"aldehyde alpha amyl cinnamic": "122-40-7",
"aldehyde anisic": "123-11-5",
"aldehyde c 08": "124-13-0",
"aldehyde c 09": "112-31-2",
"aldehyde c 11 und": "112-45-8",
"aldehyde c 18": "104-61-0",
"aldehydec 14": "104-67-6",
"aldehyde cinammic": "104-55-2",
"aldehyde cinnamic": "104-55-2",
"aldehyde cyclamen": "103-95-7",
"alcohol c-06": "111-27-3",
"alcohol cinnamic": "104-54-1",
"alcool cinnamique": "104-54-1",
"alcohol isoamyl": "123-51-3",
"alcohol phenylethyl": "60-12-8",
"alcool phenylpropylique": "122-97-4",
"alyl amyl glycolate": "67634-20-2",
"allyl amyl glycolate": "67634-20-2",
"anethol": "104-46-1",
"anisic aldehyde": "123-11-5",
"anthranilate dimethyl": "85-74-5",
"anthranilate methyl": "134-20-3",
"benzyl propionate": "122-63-4",
"benzyl cinnamate": "103-41-3",
"benzylic alcohol": "100-51-6",
"benzophenone": "119-61-9",
"benzoat benzyl": "120-51-4",
"benzoate benzyl": "120-51-4",
"benzoate methyle": "93-58-3",
"bht": "128-37-0",
"butyl quinoleine sec": "65442-31-1",
"c 7 aldehyde": "111-71-7",
"c 8 aldehyde": "124-13-0",
"c 10 aldehyde": "112-31-2",
"c 11 aldehyde": "112-44-7",
"c 12 l aldehyde": "112-54-9",
"c 12 mna": "110-41-8",
"c 12 mna aldehyde": "110-41-8",
"c 14 aldehyde": "104-67-6",
"c 16 aldehyde": "77-83-8",
"c 18 aldehyde": "104-61-0",
"calone": "28940-11-6",
"calone 1951": "28940-11-6",
"cedramber": "67874-81-1",
"cedrenol": "28231-03-0",
"cedryl acetate": "77-54-3",
"cimene para": "99-87-6",
"citronellal": "106-23-0",
"citronnellal": "106-23-0",
"citronellol": "106-22-9",
"citronnellol": "106-22-9",
"citronelol": "106-22-9",
"citronellool": "106-22-9",
"citronellyl acetate": "150-84-5",
"citronnellyl acetate": "150-84-5",
"citronellyl formate": "105-85-1",
"citronnellyl formate": "105-85-1",
"cis 3 hexenol": "928-96-1",
"cis-3 hexenol": "928-96-1",
"hexenol cis-3": "928-96-1",
"cis 3 hexenyl acetate": "3681-71-8",
"cis 3 hexenyl benzoate": "25152-85-6",
"cis 3 hexenyl salicylate": "65405-77-8",
"cis-3 hexenyl salicylate": "65405-77-8",
"cis jasmone": "488-10-8",
"clonal": "106-72-9",
"damascenone": "23696-85-7",
"damascenone total": "23696-85-7",
"damascone alpha": "43052-87-5",
"alpha damascone": "43052-87-5",
"damascone beta": "35044-57-6",
"beta damascone": "35044-57-6",
"damascone delta": "57378-72-0",
"decalactone gamma": "706-14-9",
"gamma decalactone": "706-14-9",
"dihidromyrcenol": "18479-58-8",
"dimethyl anthranylate": "85-74-5",
"eucalyptol": "470-82-6",
"eucaliptol": "470-82-6",
"exaltex": "106-02-5",
"estragol": "140-67-0",
"estragone": "140-67-0",
"floralozone": "67634-15-5",
"frambinone": "5471-51-2",
"heptalactone gamma": "105-21-5",
"heptoate allyl": "142-19-8",
"helvetolide": "141773-73-1",
"hivernal neo": "300371-33-9",
"hydoxycitronnellal": "107-75-5",
"hidroxycitronellal": "107-75-5",
"hidroxicitronelal": "107-75-5",
"hydroxycitronnellal": "107-75-5",
"indol": "120-72-9",
"iso & super": "54464-57-2",
"isobutyl quinoleine": "65442-31-1",
"isobutylquinolein": "65442-31-1",
"isobutylquinoleine": "65442-31-1",
"isobornylcyclohexanol": "3407-42-9",
"isobornyl cyclohexanol": "3407-42-9",
"jasmonal h": "101-86-0",
"jasmonal": "101365-33-7",
"jessemal": "38285-49-3",
"limonene": "5989-27-5",
"macrolide": "106-02-5",
"methyl anthranylate": "134-20-3",
"methyl heptenone": "110-93-0",
"methyl octin carbonate": "111-80-8",
"methyl octine carbonate": "111-80-8",
"methylnaphtylcetone": "93-08-3",
"methyl naphtyl cetone": "93-08-3",
"musc ambrette substitute": "83-66-9",
"mus ambrette substitute": "83-66-9",
"musc ketone": "81-14-1",
"musk t": "105-95-3",
"musc t": "105-95-3",
"musc xylol": "81-15-2",
"neo heliopan av": "5466-77-3",
"nopyl acetate": "128-51-8",
"norlimbanol": "70788-30-6",
"oxyphenylon": "5471-51-2",
"oxide rose l": "16409-43-1",
"phenylacetic aldehyde": "122-78-1",
"phenylpropyl alcohol": "122-97-4",
"phenylpropylic alcohol": "122-97-4",
"pinene alpha": "80-56-8",
"pinene beta": "127-91-3",
"rosalva": "13019-22-2",
"rosacetol": "90-17-5",
"rose oxyde": "16409-43-1",
"rose oxyde d": "16409-43-1",
"rose oxyde l": "16409-43-1",
"salicilate hexenyl cis-3": "65405-77-8",
"salicilate octyl": "118-60-5",
"salicylate octyl": "118-60-5",
"sandalor": "65113-99-5",
"sandela": "3407-42-9",
"sandranol": "28219-60-5",
"terpinene gamma": "99-85-4",
"terpenyl acetate": "80-26-2",
"trimofix o": "144020-22-4",
"veltol": "118-71-8",
"veltol plus": "4940-11-8",
"vertenex": "32210-23-4",
"vetyver acetate": "117-98-6",
"vetyveryle acetate": "117-98-6",
"vetyveryl acetate": "117-98-6",
"adoxal": "141-13-9",
"amarocit": "67674-46-8",
"amber extreme": "476332-65-7",
"amberketal": "57345-19-4",
"ambrinol": "41199-19-3",
"andrane": "13567-39-0",
"aurantiol": "89-43-0",
"bourgeonal": "18127-01-0",
"bourgeonnal": "18127-01-0",
"brahmanol": "72089-08-8",
"celeriax": "28231-03-0",
"costus oliffac": "68917-29-3",
"cuminic aldehyde": "122-03-2",
"dimetol": "13254-34-7",
"dynascone": "56973-85-4",
"ethyl amyl ketone": "541-85-5",
"hexalon": "79-78-7",
"jacinth body": "1205-17-0",
"koavone": "81786-73-4",
"mandarinal": "540-18-1",
"methylparacresol": "106-44-5",
"methyl paracresol": "106-44-5",
"methylpamplemousse": "67674-46-8",
"mousse metra": "4707-47-5",
"myrac aldehyde": "37677-14-8",
"osyrane": "41890-92-0",
"osyrol": "41890-92-0",
"paracresol": "106-44-5",
"paracresyl acetate": "140-39-6",
"paracresyl phenylacetate": "101-94-0",
"peryl aldehyde": "2111-75-3",
"phenylethyl phenylacetate": "102-20-5",
"phenylethyl dimethyl carbinol": "103-05-9",
"prunolide": "104-61-0",
"rhubafuran": "82461-14-1",
"scatol": "83-34-1",
"stemone": "22457-23-4",
"syringa aldehyde": "104-87-0",
"timberol": "70788-30-6",
"vetylione": "79-89-0",
"viridine": "101-48-4",
# Existing natural-complex profiles.
"ambreine pure": "NATURAL:68916-26-7",
"animalys": "NATURAL:68916-26-7",
"civet cnc": "NATURAL:68916-26-7",
"civette synthetic": "NATURAL:68916-26-7",
"civette synthetic / ambreine": "NATURAL:68916-26-7",
"ambrarome": "NATURAL:8023-83-4",
"ambrarome absolute": "NATURAL:8023-83-4",
"ambrarome resinoid": "NATURAL:8023-83-4",
"cardamome oil": "NATURAL:8000-66-6",
"cardamome": "NATURAL:8000-66-6",
"caraway": "99-49-0",
"caryophyllene rect": "87-44-5",
"cariophyllene rect": "87-44-5",
"caryphyllene rect": "87-44-5",
"cariophylene rect": "87-44-5",
"cinnamon leaves": "97-53-0",
"cinnamon leaves oil": "97-53-0",
"cinnamon ceylon": "97-53-0",
"cloves oil": "97-53-0",
"clove bud": "97-53-0",
"cumin oil": "122-03-2",
"cumin eo": "122-03-2",
"cumin seeds": "122-03-2",
"cedarwood": "77-53-2",
"cedarwood oil": "77-53-2",
"cedarwood virginia": "77-53-2",
"cedar virginia": "77-53-2",
"clary sage oil": "115-95-7",
"clary sage absolute oil": "115-95-7",
"officinalis sage oil": "115-95-7",
"armoise": "8023-88-9",
"artemisia": "8023-88-9",
"artemisia oil": "8023-88-9",
"basilic": "8015-73-4",
"basilic oil": "8015-73-4",
"bigarade oil": "NATURAL:8014-17-3",
"ciste oil": "8016-26-0",
"ciste absolute": "8016-26-0",
"citronella ceylon": "8000-29-1",
"citronnella oil": "8000-29-1",
"citronela ceylan": "8000-29-1",
"copahu balsam": "8001-61-4",
"coriander": "NATURAL:8008-52-4",
"coriander oil": "NATURAL:8008-52-4",
"gaiacwood": "NATURAL:8016-23-7",
"galbanum synthetic r": "68912-13-0",
"ginger co2": "8007-08-7",
"guaiacwood": "NATURAL:8016-23-7",
"gurjum balsam": "8030-55-5",
"hysope oil": "84603-66-7",
"immortelle absolute": "90131-24-1",
"iris concrete": "90045-43-5",
"irone": "79-69-6",
"irone alpha": "79-69-6",
"jasmin": "38285-49-3",
"jasmin base": "38285-49-3",
"laurus nobilis leaf": "8006-78-8",
"liatrix absolute": "68606-83-7",
"hyperessence liatrix": "68606-83-7",
"lavander": "NATURAL:8000-28-0",
"lavander oil": "NATURAL:8000-28-0",
"lavander oil 40/42": "NATURAL:8000-28-0",
"lavandin abryal": "NATURAL:8000-28-0",
"lavandin abryal oil": "NATURAL:8000-28-0",
"lavandin super oil": "NATURAL:8000-28-0",
"mandarin": "NATURAL:8008-31-9",
"mandarine": "NATURAL:8008-31-9",
"mandarine oil": "NATURAL:8008-31-9",
"mandarin vert": "NATURAL:8008-57-9",
"mandarin verte": "NATURAL:8008-57-9",
"mandarin italiana": "NATURAL:8008-57-9",
"mandarine verte italie he": "NATURAL:8008-57-9",
"mirceno": "123-35-3",
"myrceno": "123-35-3",
"myrte oil": "NATURAL:8000-25-7",
"oakmoss": "90028-68-5",
"oakmoss absolute": "90028-68-5",
"absolute oakmoss": "90028-68-5",
"orange brasil": "NATURAL:8008-57-9",
"orange seet brasil oil": "NATURAL:8008-57-9",
"orange sweet brasil oil": "NATURAL:8008-57-9",
"patchouli decol": "5986-55-0",
"patchouly": "5986-55-0",
"patchouly dm": "5986-55-0",
"patchouly dm oil": "5986-55-0",
"patchouly oil": "5986-55-0",
"petit grain": "NATURAL:8014-17-3",
"petit grain oil": "NATURAL:8014-17-3",
"petit grain paraguay": "NATURAL:8014-17-3",
"pink pepper": "NATURAL:8000-25-7",
"pepper oil": "NATURAL:8000-25-7",
"pimento oil": "8006-39-1",
"rose": "NATURAL:8007-01-0",
"sandalwood oil": "NATURAL:8006-87-9",
"vetyver oil": "NATURAL:8016-96-4",
"vetiver haiti": "NATURAL:8016-96-4",
"white thyme oil": "97-53-0",
"white thyme": "97-53-0",
"red thyme": "97-53-0",
"red thyme oil": "97-53-0",
"spearmint": "8008-79-5",
"spearmint oil": "8008-79-5",
"styrax oil": "8046-42-0",
"taget oil": "8016-84-0",
"tagets": "8016-84-0",
"tarragon oil": "8016-88-4",
"tonka beans absolute": "8024-04-2",
"tonka beans resinoi": "8024-04-2",
"violet leaves absolute": "8024-08-6",
"ylang extra": "NATURAL:8006-81-3",
"ylang extra oil": "NATURAL:8006-81-3",
"ylang oil": "NATURAL:8006-81-3",
"ylang substitute": "NATURAL:8006-81-3",
"ylang ylang extra": "NATURAL:8006-81-3",
"ylang ylang substitute": "NATURAL:8006-81-3",
# Long-tail WiseMoor OCR/French/trade-name variants needed for row coverage.
"citrone lool": "106-22-9",
"citronelool": "106-22-9",
"fleur d oranger base": "NATURAL:8014-17-3",
"fixateur": "110-27-0",
"fixateur 404": "110-27-0",
"bornyl acetate": "76-49-3",
"aspic": "NATURAL:8000-28-0",
"aspic oil from spain": "NATURAL:8000-28-0",
"blue camomile": "8015-92-7",
"camomile blue": "8015-92-7",
"roman camomile": "8015-92-7",
"roman camomile oil": "8015-92-7",
"camomile oil roman": "8015-92-7",
"camomille romaine": "8015-92-7",
"rose acetate": "141-12-8",
"acetanisol crist": "100-06-1",
"azarbre": "54464-57-2",
"globalide": "34902-57-3",
"magnolan": "27606-09-3",
"salicylate benzyl": "118-58-1",
"anise star": "8007-70-3",
"formiate citronellyl": "105-85-1",
"formiate de citronnellyle": "105-85-1",
"formiate geranyl": "105-86-2",
"nutmeg eo": "8008-45-5",
"peppermint usp": "8006-90-4",
"peppermint usa": "8006-90-4",
"patchoily oil": "5986-55-0",
"patchouli": "5986-55-0",
"patchouly indo": "5986-55-0",
"isobornyl acetate": "125-12-2",
"ambrocenide": "211299-54-6",
"ambrocenide 10": "211299-54-6",
"cyclohexyl propionate allyl": "2705-87-5",
"ciclohexyl propionate allyl": "2705-87-5",
"allyl ciclohexyl propionate": "2705-87-5",
"farenal": "141-13-9",
"orange terpenes": "5989-27-5",
"salicilate benzyl": "118-58-1",
"salicilate hexyl": "6259-76-3",
"salicilate amyl": "2050-08-0",
"salicilate isoamyl": "87-20-7",
"ally amyl glycolate": "67634-20-2",
"allyl amyl glicolate": "67634-20-2",
"aldehyde mandarin": "540-18-1",
"lavandin grosso": "NATURAL:8000-28-0",
"lavandin grosso oil": "NATURAL:8000-28-0",
"lavandin": "NATURAL:8000-28-0",
"lavandin abrialys oil": "NATURAL:8000-28-0",
"acetate geranilo": "105-87-3",
"acetyl methyl carbinol": "513-86-0",
"butirate ethyl": "105-54-4",
"caprilate ethyl": "106-32-1",
"methyl butirate ethyl": "7452-79-1",
"safranate ethyl": "35044-59-8",
"2 methyl butyl acetate": "624-41-9",
"elemi": "8023-89-0",
"salvia sclarea french": "8016-63-5",
"grapefruit": "NATURAL:8008-57-9",
"acetate citronelyl": "150-84-5",
"oxide caryophylene": "1139-30-6",
"acid acetic": "64-19-7",
"methyll heptenone": "110-93-0",
"mircene": "123-35-3",
"pheylethyl alcohol": "60-12-8",
"phenylrthyl alcohol": "60-12-8",
"phenylethyl alcohool": "60-12-8",
"alcool phenylethylique": "60-12-8",
"alcohol 39c": "60-12-8",
"butirate d m b c": "10094-34-5",
"dmbc butyrate": "10094-34-5",
"fructalate": "72903-27-6",
"monopropilenglicol": "57-55-6",
"neobutenone alpha": "56973-85-4",
"oriclone": "16587-71-6",
"salicilate octilo": "118-60-5",
"zenolide": "54982-83-1",
"eucalyptus globulus 80": "470-82-6",
"eucalyptus globulus oil": "470-82-6",
"eucalyptus": "470-82-6",
"diphenyl oxide": "101-84-8",
"diphenyl oxyde": "101-84-8",
"aldehyde c8": "124-13-0",
"methyl dihydro jasmonate": "24851-98-7",
"helvetolide pur": "141773-73-1",
"canthoxal": "5462-06-6",
"canthoxal iff": "5462-06-6",
"cis 2 hxenyl acetate": "2497-18-9",
"ethyl isovalerate": "108-64-5",
"orange brazil": "NATURAL:8008-57-9",
"ethyl aceto acetate": "141-97-9",
"hidroxycitrone lool": "106-22-9",
"hidroxycitronelool": "106-22-9",
"hydroxycitonnellal": "107-75-5",
"floropal": "5182-36-5",
"d h m": "18479-58-8",
"dihydrmyrcenol": "18479-58-8",
"menta arvensis": "8006-90-4",
"ciste labdanum sis absolute": "8016-26-0",
"abs ciste labdanum": "8016-26-0",
"cinnamyle acetate": "21040-45-9",
"paramethyquinoleine": "491-35-0",
"tetrahydroparamethylquinoleine": "635-46-1",
"caprylic acid": "124-07-2",
"linalyl propinate": "144-39-8",
"birch tree oil": "8001-88-5",
"valerianic acid": "109-52-4",
"methylhexylcetone": "111-13-7",
"carvyl acetate": "97-42-7",
"phenylrthyl acetate": "103-45-7",
"methyl phenyl acetate": "101-41-7",
"methyl phenylacetate": "101-41-7",
"phenylethyl isobutyrate": "103-48-0",
"methyl phenylacetate": "101-41-7",
"menthone": "10458-14-7",
"isomenthone": "491-07-6",
"rosalva iff": "13019-22-2",
"ethyl citrate": "77-93-0",
"benzyl butyrate": "103-37-7",
"heliotrpin": "120-57-0",
"heliotropin": "120-57-0",
"gamma octalactone": "104-50-7",
"c 12 l aldehyde 50": "112-54-9",
# Formula-by-formula recovery for WiseMoor rows rejected by the coverage gate.
"acetate de linalyle": "115-95-7",
"acetate linalyle": "115-95-7",
"acetate d isobornyle": "125-12-2",
"ylang s": "NATURAL:8006-81-3",
"geraniul egypt oil": "NATURAL:8007-01-0",
"coriandre russe": "NATURAL:8008-52-4",
"cypres biolande": "NATURAL:8014-17-3",
"salicylate d amyle": "2050-08-0",
"romarin": "NATURAL:8000-25-7",
"thym rouge": "97-53-0",
"cumin": "122-03-2",
"black pepper": "87-44-5",
"black pepper oil": "87-44-5",
"cypress oil": "NATURAL:8014-17-3",
"vetiver oil haiti": "NATURAL:8016-96-4",
"vetiver oil bourbon": "NATURAL:8016-96-4",
"cistus oil spain": "8016-26-0",
"vetival": "117-98-6",
"santalex": "NATURAL:8006-87-9",
"tetrahtdromugol": "78-69-3",
"tetrahydromugol": "78-69-3",
"methyl pamplemousse giv": "67674-46-8",
"veloutone": "65443-14-3",
"veloutone firm": "65443-14-3",
"bourgennal": "18127-01-0",
"benzyl salicilate": "118-58-1",
"magnolia oil lmr": "24851-98-7",
"muscone": "541-91-3",
"neo heliopan bb": "131-57-7",
"abs jasmin maroc": "38285-49-3",
"viridine padma": "101-48-4",
"ethyl acetyl acetate": "141-97-9",
"jasmin 1103": "38285-49-3",
"dimethyl heptanol": "106-21-8",
"myrthe oil": "NATURAL:8000-25-7",
"lime distilled": "NATURAL:8008-57-9",
"phenylethy alcohol": "60-12-8",
"tagete oil": "8016-84-0",
"marjolaine": "NATURAL:8000-25-7",
"thyme oil": "97-53-0",
"marjoliane dl": "NATURAL:8000-25-7",
"orange": "NATURAL:8008-57-9",
"vetyverol": "117-98-6",
"verbena oil": "106-22-9",
"clary sage absolute": "8016-63-5",
"cedar leaf oil": "8007-20-3",
"ylang absolute oil": "NATURAL:8006-81-3",
"civet synth firm": "NATURAL:68916-26-7",
"jasmin 231 type": "38285-49-3",
"musc k": "81-14-1",
"oakmoss absolute solute": "90028-68-5",
"rose absolute type": "NATURAL:8007-01-0",
"ambretolide": "7779-50-2",
"cedarwood texas oil": "NATURAL:8006-87-9",
"cedarwood oil texas": "NATURAL:8006-87-9",
"dimethyl phenyl ethyl carbynol": "103-05-9",
"dipropylen glycol": "25265-71-8",
"isopropyl miristate": "110-27-0",
"iso propyl myristate": "110-27-0",
"sage sclarea oil": "8016-63-5",
"vetyvert haiti oil": "NATURAL:8016-96-4",
"ambreine": "NATURAL:68916-26-7",
"camphre": "76-22-2",
"cedarleaf oil": "8007-20-3",
"copaiba balsam oil": "8001-61-4",
"cuminaldehyde": "122-03-2",
"labdanum oil": "8016-26-0",
"tonka bean absolute": "8024-04-2",
"cedre he bois atlas": "NATURAL:8006-87-9",
"santal he inde": "NATURAL:8006-87-9",
"acetate cedryle": "77-54-3",
"vetyver ess java": "NATURAL:8016-96-4",
"petitgrain he paraguay": "NATURAL:8014-17-3",
"salicylate cis 3 hexenyle": "65405-77-8",
"phenylethyl formate": "104-62-1",
"amyl butyrate iso": "106-27-4",
"ethyl propionate": "105-37-3",
"boccoxim": "81782-77-6",
"methyloctalactone": "39212-23-2",
"clary sage absolute oil": "8016-63-5",
"c 19 aldehyde": "104-67-6",
"speatmint": "8008-79-5",
"dihydrojasmone": "95-41-0",
"taget absolute": "8016-84-0",
"irisone alpha giv": "79-69-6",
"rose bulgarian oil reconstitued": "NATURAL:8007-01-0",
"ylang extra absolute": "NATURAL:8006-81-3",
"carbitol": "111-90-0",
"methyl cyclo pentenolene butyrate": "80-71-7",
"sauge sclaree russe": "8016-63-5",
"girofles clous": "97-53-0",
"acetate de neryle": "141-12-8",
"acetate de geranyle": "105-87-3",
"acetate de citronnellyle": "150-84-5",
"gurjum oil": "8030-55-5",
"poivre": "87-44-5",
"oxyde de rose d": "16409-43-1",
"isobutyl isobutyrate": "97-85-8",
"citronnelle java": "8000-29-1",
"orange florida": "NATURAL:8008-57-9",
"gamma terpinene": "99-85-4",
"dilmrthyl anthranylate": "85-74-5",
"thymol": "89-83-8",
"alcool phenylethylique": "60-12-8",
"geraniol": "106-24-1",
"menthyl acetate": "89-48-5",
"menthe bresil": "8006-90-4",
"c 10 adehyde": "112-31-2",
"citeronnellyl acetate": "150-84-5",
"ethyl caproate": "123-66-0",
"aldehyde c 7 e": "111-71-7",
"methyl nonyl ketone": "112-12-9",
"isopulegol purified": "89-79-2",
"para cymene": "99-87-6",
"ald c 11 undecylic": "112-44-7",
"tangerine oil": "NATURAL:8008-57-9",
"dioctyl adipate": "123-79-5",
"dipentene": "5989-27-5",
"lime terpenes": "5989-27-5",
"c 9 aldehyde": "112-31-2",
"vetyver terpenes": "NATURAL:8016-96-4",
"animalys 1745": "NATURAL:68916-26-7",
"butyric acid": "107-92-6",
"amyris oil": "NATURAL:8006-87-9",
"bouleau oil rectified": "8001-88-5",
"beta pinene": "127-91-3",
"gappefruit body": "NATURAL:8008-57-9",
"amboxan henkel": "3738-00-9",
"dynamone cas 8016 26 0": "8016-26-0",
"dimyrcetol": "18479-58-8",
"iso amyl butyrate": "106-27-4",
"paratoluic aldehyde": "104-87-0",
"hexyl formate": "629-33-4",
"hexyl butyrate": "2639-63-6",
"iso pulegol": "89-79-2",
"citronnellyl isobutyrate": "97-89-2",
"estragon oil": "8016-88-4",
"diphenylmethane": "101-81-5",
"c 9 alcohol": "143-08-8",
"iso valerianic acid": "503-74-2",
"hydratropic aldehyde dimethylacetal": "90-87-9",
"hey absolute": "91-64-5",
"galbex": "68912-13-0",
"rosone bba": "16409-43-1",
"cortex aldehyde": "68039-49-6",
"c 11 alcool lenique": "112-42-5",
"citeronnellyl acetate": "150-84-5",
"methyl 2 pyrazinz": "109-08-0",
"dibutyl sulfure iff": "544-40-1",
}
def _wisemoor_lookup_key(name: str) -> str:
norm = unicodedata.normalize("NFKD", str(name).strip().lower())
norm = "".join(ch for ch in norm if not unicodedata.combining(ch))
norm = re.sub(r"[^a-z0-9&]+", " ", norm)
return " ".join(norm.split())
WISEMOOR_NORMALIZED_OVERRIDES = {
_wisemoor_lookup_key(raw): cas for raw, cas in WISEMOOR_NAME_OVERRIDES.items()
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--input", type=Path, default=Path("data/wisemoor_free_formulas.sanitized.jsonl"))
parser.add_argument("--output", type=Path, default=Path("data/wisemoor_training_candidates.jsonl"))
parser.add_argument("--audit-output", type=Path, default=Path("artifacts/wisemoor_training_candidate_audit.json"))
parser.add_argument("--min-row-coverage", type=float, default=0.80)
parser.add_argument("--min-amount-coverage", type=float, default=0.80)
parser.add_argument("--min-resolved-components", type=int, default=3)
return parser.parse_args()
def load_jsonl(path: Path) -> list[dict[str, Any]]:
with path.open(encoding="utf-8") as handle:
return [json.loads(line) for line in handle if line.strip()]
def normalize_formula(parts_by_cas: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
total = sum(float(item["amount"]) for item in parts_by_cas.values())
if total <= 0:
return []
formula = []
for cas, item in sorted(parts_by_cas.items()):
formula.append(
{
"cas": cas,
"name": item["name"],
"weight_fraction": float(item["amount"]) / total,
}
)
return formula
def resolve_wisemoor_name(name: str, mapping: dict[str, str]) -> str | None:
norm = " ".join(str(name).strip().lower().split())
if norm in WISEMOOR_NAME_OVERRIDES:
return WISEMOOR_NAME_OVERRIDES[norm]
lookup = _wisemoor_lookup_key(name)
if lookup in WISEMOOR_NORMALIZED_OVERRIDES:
return WISEMOOR_NORMALIZED_OVERRIDES[lookup]
compact = lookup.replace(" ", "")
if compact in WISEMOOR_NORMALIZED_OVERRIDES:
return WISEMOOR_NORMALIZED_OVERRIDES[compact]
cas = map_ingredient(name, mapping)
if cas:
return cas
aldehyde_match = re.match(r"^c\s+(\d+)(?:\s+([a-z]+))?\s+aldehyde$", norm)
if aldehyde_match:
chain, qualifier = aldehyde_match.groups()
candidates = [
f"aldehyde c-{chain} {qualifier}".strip(),
f"aldehyde c-{chain}",
]
for candidate in candidates:
if candidate in WISEMOOR_NAME_OVERRIDES:
return WISEMOOR_NAME_OVERRIDES[candidate]
cas = map_ingredient(candidate, mapping)
if cas:
return cas
return None
def convert_record(
record: dict[str, Any],
mapping: dict[str, str],
*,
min_row_coverage: float,
min_amount_coverage: float,
min_resolved_components: int,
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
raw_rows = record.get("formula", {}).get("formula_rows") or []
rows = [
row
for row in raw_rows
if re.search(r"[A-Za-z]", str(row.get("ingredient") or ""))
and float(row.get("amount") or 0.0) > 0
]
total_rows = len(rows)
total_amount = sum(float(row.get("amount") or 0.0) for row in rows)
parts_by_cas: dict[str, dict[str, Any]] = {}
unresolved: list[dict[str, Any]] = []
resolved_rows = 0
resolved_amount = 0.0
for row in rows:
name = str(row.get("ingredient") or "").strip()
amount = float(row.get("amount") or 0.0)
if not name or amount <= 0:
continue
cas = row.get("cas") or resolve_wisemoor_name(name, mapping)
if cas:
resolved_rows += 1
resolved_amount += amount
existing = parts_by_cas.setdefault(cas, {"name": name, "amount": 0.0})
existing["amount"] += amount
else:
unresolved.append({"ingredient": name, "amount": amount})
row_coverage = resolved_rows / total_rows if total_rows else 0.0
amount_coverage = resolved_amount / total_amount if total_amount else 0.0
formula = normalize_formula(parts_by_cas)
accepted = (
row_coverage >= min_row_coverage
and amount_coverage >= min_amount_coverage
and len(formula) >= min_resolved_components
)
audit = {
"product_id": record.get("product_id"),
"name": record.get("name"),
"total_rows": total_rows,
"resolved_rows": resolved_rows,
"row_coverage": row_coverage,
"total_amount": total_amount,
"resolved_amount": resolved_amount,
"amount_coverage": amount_coverage,
"resolved_components": len(formula),
"accepted": accepted,
"unresolved": unresolved,
}
if not accepted:
return None, audit
candidate = {
"formula_id": f"WISEMOOR_{record.get('product_id')}",
"source": "wisemoor_labtorium_free_sanitized",
"source_url": record.get("source_url"),
"name": record.get("name"),
# Preserve source-observed formula text as provenance. This is useful
# for retrieval and later human/AI-assisted extraction, but is not an
# assessor label and must not be promoted to one implicitly.
"description": record.get("description"),
"genre": "wisemoor_formula",
"notes": record.get("notes"),
"formula": formula,
"metadata": {
"product_id": record.get("product_id"),
"slug": record.get("slug"),
"row_coverage": row_coverage,
"amount_coverage": amount_coverage,
"unresolved_count": len(unresolved),
"sanitized_source": str(Path("data/wisemoor_free_formulas.sanitized.jsonl")),
"description_provenance": (
"source_observed_wisemoor_product_text"
if record.get("description") else None
),
},
}
return candidate, audit
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
def main() -> None:
args = parse_args()
registry = AromaRegistry()
mapping = build_tgsc_mapping(registry)
candidates: list[dict[str, Any]] = []
audits: list[dict[str, Any]] = []
unresolved_counter: Counter[str] = Counter()
for record in load_jsonl(args.input):
candidate, audit = convert_record(
record,
mapping,
min_row_coverage=args.min_row_coverage,
min_amount_coverage=args.min_amount_coverage,
min_resolved_components=args.min_resolved_components,
)
audits.append(audit)
for item in audit["unresolved"]:
unresolved_counter[item["ingredient"]] += 1
if candidate:
candidates.append(candidate)
write_jsonl(args.output, candidates)
by_reason = defaultdict(int)
for audit in audits:
if audit["accepted"]:
by_reason["accepted"] += 1
else:
if audit["row_coverage"] < args.min_row_coverage:
by_reason["low_row_coverage"] += 1
if audit["amount_coverage"] < args.min_amount_coverage:
by_reason["low_amount_coverage"] += 1
if audit["resolved_components"] < args.min_resolved_components:
by_reason["too_few_components"] += 1
summary = {
"source": str(args.input),
"output": str(args.output),
"records_total": len(audits),
"records_accepted": len(candidates),
"records_rejected": len(audits) - len(candidates),
"thresholds": {
"min_row_coverage": args.min_row_coverage,
"min_amount_coverage": args.min_amount_coverage,
"min_resolved_components": args.min_resolved_components,
},
"rejection_counts": dict(sorted(by_reason.items())),
"top_unresolved": unresolved_counter.most_common(100),
"records": audits,
}
args.audit_output.parent.mkdir(parents=True, exist_ok=True)
args.audit_output.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({k: summary[k] for k in ["records_total", "records_accepted", "records_rejected"]}, indent=2))
if __name__ == "__main__":
main()