File size: 21,790 Bytes
f3ff0be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
#!/usr/bin/env python3
"""Build and freeze the v2 genre challenge set without inspecting embeddings."""

from __future__ import annotations

import argparse
from collections import Counter, defaultdict
import hashlib
import json
from pathlib import Path
from typing import Any, Iterable

import numpy as np
from rdkit import Chem, RDLogger
from rdkit.Chem import Descriptors

from pino.genre_benchmark import SOLVENTS
from pino.registry import AromaRegistry
from pino.thermo.naturals import NATURAL_PROFILES


SUBSTANTIVE_GENRES = ("amber_oriental", "citrus_cologne", "floral_woody", "fougere")
ELEMENTS = ("C", "H", "N", "O", "F", "P", "S", "Cl", "Br", "I", "B", "Si")
RDLogger.DisableLog("rdApp.error")


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def identity(component: dict[str, Any]) -> str:
    return str(component.get("cas") or component.get("smiles") or component.get("name") or "").strip().lower()


def genre(row: dict[str, Any]) -> str:
    return str(row.get("genre") or row.get("metadata", {}).get("generation_strategy") or "wildcard")


def positive_active_components(row: dict[str, Any]) -> list[dict[str, Any]]:
    return [
        component for component in row.get("formula", [])
        if identity(component) not in SOLVENTS and float(component.get("weight_fraction", 0.0)) > 0
    ]


def _natural_profile(identifier: str) -> dict[str, Any] | None:
    key = identifier.strip()
    return NATURAL_PROFILES.get(key) or NATURAL_PROFILES.get(f"NATURAL:{key}")


def _resolve_smiles(component: dict[str, Any], registry: AromaRegistry | None) -> str | None:
    """Resolve the structure carried inline, in a SMILES: key, or by registry id."""
    inline = str(component.get("smiles") or "").strip()
    identifier = str(component.get("cas") or component.get("name") or "").strip()
    candidates = [inline]
    if identifier.upper().startswith("SMILES:"):
        candidates.append(identifier.split(":", 1)[1])
    if registry is not None and identifier:
        record = registry.get(identifier)
        if record:
            candidates.append(str(record.get("smiles") or "").strip())
    for candidate in candidates:
        if not candidate:
            continue
        if candidate.upper().startswith("SMILES:"):
            candidate = candidate.split(":", 1)[1]
        mol = Chem.MolFromSmiles(candidate)
        if mol is not None and len(Chem.GetMolFrags(mol)) == 1:
            return Chem.MolToSmiles(mol, canonical=True)
    return None


def resolved_active_components(
    row: dict[str, Any], registry: AromaRegistry | None = None
) -> tuple[list[dict[str, Any]], list[str]]:
    """Expand complex materials and aggregate their resolved pure constituents."""
    aggregated: dict[str, dict[str, Any]] = {}
    unresolved: list[str] = []

    def add(component: dict[str, Any], weight: float, lineage: tuple[str, ...] = ()) -> None:
        identifier = str(component.get("cas") or component.get("name") or "").strip()
        profile = _natural_profile(identifier)
        if profile:
            profile_key = identifier.removeprefix("NATURAL:")
            if profile_key in lineage:
                unresolved.append(identifier)
                return
            for constituent, fraction in profile["constituents"].items():
                add({"cas": constituent}, weight * float(fraction), lineage + (profile_key,))
            return
        smiles = _resolve_smiles(component, registry)
        if not smiles:
            unresolved.append(identifier or str(component.get("smiles") or "<missing identifier>"))
            return
        entry = aggregated.setdefault(smiles, {"cas": identifier, "smiles": smiles, "weight_fraction": 0.0})
        entry["weight_fraction"] += weight

    for component in positive_active_components(row):
        add(component, float(component["weight_fraction"]))
    return list(aggregated.values()), unresolved


def characterize(
    row: dict[str, Any], source_index: int, registry: AromaRegistry | None = None
) -> dict[str, Any]:
    active, unresolved = resolved_active_components(row, registry)
    weights = np.asarray([float(c["weight_fraction"]) for c in active], dtype=float)
    element_mass = Counter()
    valid = not unresolved
    for component, weight in zip(active, weights):
        smiles = str(component["smiles"])
        mol = Chem.MolFromSmiles(smiles)
        if mol is None or len(Chem.GetMolFrags(mol)) != 1:
            valid = False
            continue
        molecular_weight = Descriptors.MolWt(mol)
        if molecular_weight <= 0:
            valid = False
            continue
        for atom in mol.GetAtoms():
            element_mass[atom.GetSymbol()] += weight * atom.GetMass() / molecular_weight
    total = float(weights.sum())
    proportions = weights / total if total else weights
    hhi = float(np.square(proportions).sum()) if len(proportions) else 0.0
    return {
        "source_index": source_index,
        "genre": genre(row),
        "active_count": len(active),
        "structurally_valid": bool(active) and valid,
        "unresolved_identifiers": sorted(set(unresolved)),
        "element_vector": [float(element_mass[e]) for e in ELEMENTS],
        "element_other": float(sum(v for e, v in element_mass.items() if e not in ELEMENTS)),
        "hhi": hhi,
        "top_weight_share": float(proportions.max()) if len(proportions) else 0.0,
        "ingredients": sorted({str(c["smiles"]) for c in active}),
        "natural_weight_share": natural_weight_share(row),
    }


def natural_weight_share(row: dict[str, Any]) -> float:
    """Fraction of the active formula entered as a natural/complex material."""
    active = positive_active_components(row)
    total = sum(float(c["weight_fraction"]) for c in active)
    if not total:
        return 0.0
    natural = sum(
        float(c["weight_fraction"]) for c in active
        if _natural_profile(str(c.get("cas") or c.get("name") or "")) is not None
    )
    return natural / total


def size_bin(active_count: int) -> str:
    if active_count == 2:
        return "2"
    if active_count <= 5:
        return "3-5"
    if active_count <= 10:
        return "6-10"
    return "11+"


def composition_match(a: dict[str, Any], b: dict[str, Any]) -> bool:
    """Predeclared hard-negative caliper using label-free formula properties."""
    if size_bin(a["active_count"]) != size_bin(b["active_count"]):
        return False
    va, vb = np.asarray(a["element_vector"]), np.asarray(b["element_vector"])
    denom = float(np.linalg.norm(va) * np.linalg.norm(vb))
    cosine = float(np.dot(va, vb) / denom) if denom else 0.0
    return (
        cosine >= 0.98
        and abs(a["hhi"] - b["hhi"]) <= 0.10
        and abs(a["top_weight_share"] - b["top_weight_share"]) <= 0.10
        and not (set(a["ingredients"]) & set(b["ingredients"]))
    )


def exact_formula_key(row: dict[str, Any]) -> tuple[tuple[str, int], ...]:
    """Order-independent resolved formula key, with weights rounded to 1e-6."""
    return tuple(sorted(
        (str(c["smiles"]), round(float(c["weight_fraction"]) * 1_000_000))
        for c in row["resolved_components"]
    ))


def near_duplicate(a: dict[str, Any], b: dict[str, Any]) -> bool:
    """Conservative near-duplicate rule: same ingredients and nearly same weights."""
    aw = {c["smiles"]: float(c["weight_fraction"]) for c in a["resolved_components"]}
    bw = {c["smiles"]: float(c["weight_fraction"]) for c in b["resolved_components"]}
    if aw.keys() != bw.keys():
        return False
    at, bt = sum(aw.values()), sum(bw.values())
    return max(abs(aw[k] / at - bw[k] / bt) for k in aw) <= 0.02


def collapse_duplicates(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    """Keep the lowest source index from each exact/near formula family."""
    families: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        families[tuple(row["ingredients"])].append(row)
    kept, clusters = [], []
    for ingredient_key in sorted(families):
        representatives: list[dict[str, Any]] = []
        for row in sorted(families[ingredient_key], key=lambda r: r["source_index"]):
            match = next((r for r in representatives if near_duplicate(r, row)), None)
            if match is None:
                representatives.append(row)
                kept.append(row)
            else:
                clusters.append({"representative": match["source_index"], "collapsed": row["source_index"]})
    return sorted(kept, key=lambda r: r["source_index"]), {
        "input_records": len(rows), "representatives": len(kept),
        "collapsed_records": len(clusters), "clusters": clusters,
        "rule": "identical resolved ingredient set and maximum normalized weight difference <= 0.02",
    }


def selection_distance(a: dict[str, Any], b: dict[str, Any]) -> float:
    va, vb = np.asarray(a["element_vector"]), np.asarray(b["element_vector"])
    cosine = float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb)))
    return ((1 - cosine) / 0.02 + abs(a["hhi"] - b["hhi"]) / .10
            + abs(a["top_weight_share"] - b["top_weight_share"]) / .10
            + abs(a["natural_weight_share"] - b["natural_weight_share"]))


def select_balanced_challenge(
    records: list[dict[str, Any]], registry: AromaRegistry | None, per_genre: int
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    """Deterministically select disjoint, pairwise-calipered four-genre blocks."""
    candidates = []
    for index, source in enumerate(records):
        row = characterize(source, index, registry)
        if row["genre"] not in SUBSTANTIVE_GENRES or row["active_count"] < 2 or not row["structurally_valid"]:
            continue
        row["formula_id"] = source.get("formula_id") or source.get("metadata", {}).get("formula_id")
        row["resolved_components"], _ = resolved_active_components(source, registry)
        candidates.append(row)
    candidates, duplicate_audit = collapse_duplicates(candidates)
    by_genre = {g: [r for r in candidates if r["genre"] == g] for g in SUBSTANTIVE_GENRES}
    used: set[int] = set()
    blocks: list[list[dict[str, Any]]] = []
    anchor_genre = min(SUBSTANTIVE_GENRES, key=lambda g: len(by_genre[g]))
    bins = ("3-5", "6-10", "11+")
    common = {b: min(sum(size_bin(r["active_count"]) == b for r in by_genre[g])
                         for g in SUBSTANTIVE_GENRES) for b in bins}
    denominator = sum(common.values())
    raw_quota = {b: per_genre * common[b] / denominator for b in bins}
    bin_quota = {b: int(raw_quota[b]) for b in bins}
    for b in sorted(bins, key=lambda x: (-(raw_quota[x] - bin_quota[x]), x))[:per_genre - sum(bin_quota.values())]:
        bin_quota[b] += 1
    anchors = sorted(by_genre[anchor_genre], key=lambda r: (size_bin(r["active_count"]), r["hhi"], r["source_index"]))
    selected_bins: Counter[str] = Counter()
    for anchor in anchors:
        if anchor["source_index"] in used:
            continue
        anchor_bin = size_bin(anchor["active_count"])
        if anchor_bin not in bin_quota or selected_bins[anchor_bin] >= bin_quota[anchor_bin]:
            continue
        block = [anchor]
        for name in SUBSTANTIVE_GENRES:
            if name == anchor["genre"]:
                continue
            feasible = [r for r in by_genre[name] if r["source_index"] not in used
                        and all(composition_match(r, chosen) for chosen in block)]
            if not feasible:
                break
            block.append(min(feasible, key=lambda r: (sum(selection_distance(r, x) for x in block), r["source_index"])))
        if len(block) == len(SUBSTANTIVE_GENRES):
            blocks.append(block)
            used.update(r["source_index"] for r in block)
            selected_bins[anchor_bin] += 1
            if len(blocks) == per_genre:
                break
    # If a scarce bin cannot realize its proportional quota as a complete
    # pairwise clique, fill from any remaining bin without relaxing calipers.
    if len(blocks) < per_genre:
        for anchor in anchors:
            if anchor["source_index"] in used:
                continue
            block = [anchor]
            for name in SUBSTANTIVE_GENRES:
                if name == anchor["genre"]:
                    continue
                feasible = [r for r in by_genre[name] if r["source_index"] not in used
                            and all(composition_match(r, chosen) for chosen in block)]
                if not feasible:
                    break
                block.append(min(feasible, key=lambda r: (
                    sum(selection_distance(r, x) for x in block), r["source_index"])))
            if len(block) == len(SUBSTANTIVE_GENRES):
                blocks.append(block)
                used.update(r["source_index"] for r in block)
                selected_bins[size_bin(anchor["active_count"])] += 1
                if len(blocks) == per_genre:
                    break
    if len(blocks) < per_genre:
        raise ValueError(f"only {len(blocks)} pairwise-matched blocks available; requested {per_genre}")
    selected = [r for block in blocks for r in sorted(block, key=lambda r: r["genre"])]
    lean = [{k: r[k] for k in ("source_index", "formula_id", "genre", "active_count", "element_vector",
                                "element_other", "hhi", "top_weight_share", "natural_weight_share", "ingredients")}
            for r in selected]
    audit = {
        "selection_method": "deterministic greedy pairwise-calipered four-genre blocks",
        "blocks": len(blocks), "records": len(lean), "per_genre": count_by_genre(lean),
        "size_bin_quota": bin_quota,
        "source_indices_unique": len({r["source_index"] for r in lean}) == len(lean),
        "duplicate_collapse": duplicate_audit,
        "size_bins_by_genre": {g: dict(sorted(Counter(size_bin(r["active_count"]) for r in lean if r["genre"] == g).items())) for g in SUBSTANTIVE_GENRES},
        "natural_weight_share_by_genre": {g: {"mean": float(np.mean([r["natural_weight_share"] for r in lean if r["genre"] == g])), "nonzero": sum(r["natural_weight_share"] > 0 for r in lean if r["genre"] == g)} for g in SUBSTANTIVE_GENRES},
    }
    return lean, audit


def count_by_genre(rows: Iterable[dict[str, Any]]) -> dict[str, int]:
    counts = Counter(row["genre"] for row in rows)
    return {name: counts.get(name, 0) for name in (*SUBSTANTIVE_GENRES, "wildcard")}


def census(records: list[dict[str, Any]], registry: AromaRegistry | None = None) -> dict[str, Any]:
    characterized = [characterize(row, i, registry) for i, row in enumerate(records)]
    multi = [r for r in characterized if r["active_count"] >= 2]
    valid = [r for r in multi if r["structurally_valid"]]
    substantive = [r for r in valid if r["genre"] in SUBSTANTIVE_GENRES]

    partners: dict[int, set[str]] = defaultdict(set)
    pair_counts: Counter[tuple[str, str]] = Counter()
    for i, left in enumerate(substantive):
        for right in substantive[i + 1:]:
            if left["genre"] == right["genre"] or not composition_match(left, right):
                continue
            pair = tuple(sorted((left["genre"], right["genre"])))
            pair_counts[pair] += 1
            partners[left["source_index"]].add(right["genre"])
            partners[right["source_index"]].add(left["genre"])

    matchable = [r for r in substantive if partners[r["source_index"]]]
    per_genre = {}
    for name in SUBSTANTIVE_GENRES:
        eligible = [r for r in substantive if r["genre"] == name]
        matched = [r for r in eligible if partners[r["source_index"]]]
        per_genre[name] = {
            "eligible": len(eligible),
            "composition_matchable": len(matched),
            "matchable_to_all_three_other_genres": sum(len(partners[r["source_index"]]) == 3 for r in eligible),
            "by_size_bin": dict(sorted(Counter(size_bin(r["active_count"]) for r in eligible).items())),
        }
    return {
        "records": len(records),
        "resolution": {
            "records_with_unresolved_active_identifiers": sum(bool(r["unresolved_identifiers"]) for r in characterized),
            "unresolved_identifier_counts": dict(sorted(Counter(
                identifier for r in characterized for identifier in r["unresolved_identifiers"]
            ).items())),
        },
        "attrition": {
            "all": count_by_genre(characterized),
            "multi_component": count_by_genre(multi),
            "multi_component_structurally_valid": count_by_genre(valid),
            "substantive_composition_matchable": count_by_genre(matchable),
        },
        "per_substantive_genre": per_genre,
        "cross_genre_candidate_pairs": {"__vs__".join(pair): count for pair, count in sorted(pair_counts.items())},
        "maximum_balanced_pool_from_individually_matchable_records": min(
            (per_genre[g]["composition_matchable"] for g in SUBSTANTIVE_GENRES), default=0
        ),
        "wildcard_open_set_pool": {
            "multi_component_structurally_valid": sum(r["genre"] == "wildcard" for r in valid),
            "singleton_sanity_check": sum(r["genre"] == "wildcard" and r["active_count"] == 1 for r in characterized),
        },
    }


def specification(dataset: Path, report: dict[str, Any], selected: bool = False) -> dict[str, Any]:
    return {
        "protocol_version": 2,
        "status": ("frozen_challenge_rows_selected; embeddings_and_outcomes_not_inspected" if selected else
                   "frozen_design_and_feasibility_census; challenge_rows_not_selected; embeddings_not_inspected"),
        "dataset": {"path": str(dataset), "sha256": sha256(dataset), "records": report["records"]},
        "primary_population": {
            "genres": list(SUBSTANTIVE_GENRES),
            "requirements": [
                "at least two positive-weight non-solvent resolved constituents",
                "natural/complex materials expanded recursively",
                "CAS and SMILES: identifiers resolve to one-fragment canonical SMILES",
                "duplicate resolved constituents aggregated before characterization",
            ],
            "excluded": "wildcard and singleton records",
        },
        "matching": {
            "size_bins": ["2", "3-5", "6-10", "11+"],
            "element_mass_cosine_minimum": 0.98,
            "concentration_hhi_absolute_tolerance": 0.10,
            "top_weight_share_absolute_tolerance": 0.10,
            "shared_active_ingredient_maximum": 0,
            "selection_rule": "balanced across four genres; optimize match coverage without using embeddings or outcomes",
        },
        "tasks": {
            "primary": [
                "real formula versus non-identity weight permutation",
                "real formula versus ingredient-matched decoy",
                "same-genre versus different-genre composition-matched retrieval",
            ],
            "residual_value": "nested cross-validation: composition alone versus composition plus learned embedding",
            "open_set": "wildcard is rejection-only and never a fifth primary class",
            "sanity_check": "report singleton wildcard classification separately",
        },
        "metrics": {
            "classification": ["macro_f1", "balanced_accuracy", "per_class_recall"],
            "contrastive": ["paired_accuracy", "roc_auc"],
            "retrieval": ["same_genre_enrichment", "mean_reciprocal_rank"],
            "uncertainty": "formula-level paired bootstrap 95% intervals",
            "raw_accuracy_is_primary": False,
        },
        "leakage_controls": [
            "all preprocessing and hyperparameter selection fit inside training folds",
            "exact formula and active ingredient identities disjoint across outer folds",
            "decoys and contrastive variants remain in the source formula's fold",
            "final row selection is frozen before learned embeddings are inspected",
        ],
        "go_no_go": {
            "minimum_individually_matchable_records_per_genre": 20,
            "minimum_candidate_pairs_per_genre_pair": 20,
            "pass": report["maximum_balanced_pool_from_individually_matchable_records"] >= 20
                and all(v >= 20 for v in report["cross_genre_candidate_pairs"].values())
                and len(report["cross_genre_candidate_pairs"]) == 6,
        },
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--dataset", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--registry", default=None, help="Aroma registry SQLite path")
    parser.add_argument("--per-genre", type=int, default=50,
                        help="Rows per substantive genre in the frozen challenge set")
    args = parser.parse_args()
    dataset, output = Path(args.dataset), Path(args.output)
    with dataset.open(encoding="utf-8") as handle:
        records = [json.loads(line) for line in handle if line.strip()]
    with AromaRegistry(args.registry) as registry:
        report = census(records, registry)
        selected, audit = select_balanced_challenge(records, registry, args.per_genre)
    result = {"specification": specification(dataset, report, selected=True),
              "feasibility_census": report, "selection_audit": audit,
              "challenge_records": selected}
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(json.dumps(result, indent=2) + "\n")
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()