File size: 6,481 Bytes
2e175db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Combine per-source manifests into a single master manifest.

The master manifest is the authoritative legal record for the dataset. It is:
  • Committed alongside the trained model (so the lineage is auditable).
  • Used by the training script (no image is loaded that isn't in the manifest).
  • The first thing a lawyer or auditor will ask to see.

Stage 3A manifest schema
------------------------
Required legal/training fields:
  path, class, source, license, license_url, sha256

Optional generator metadata fields:
  generator, model_family, model_id, prompt, seed, width, height,
  generation_params_json

Missing optional values are written as empty strings. This keeps Stage 2
manifest fragments readable while giving Stage 3A scripts stable columns for
per-generator evaluation.

Usage
-----
    python scripts/dataset/build_manifest.py \
        --inputs data/raw/real_manifest.csv data/raw/ai_generated_manifest.csv \
        --out data/manifest.csv

    python scripts/dataset/build_manifest.py \
        --input-dir data/raw \
        --out data/manifest.csv
"""
from __future__ import annotations

import argparse
import csv
from pathlib import Path

from generation_utils import APPROVED_GENERATORS

REQUIRED_FIELDS = ["path", "class", "source", "license", "license_url", "sha256"]
OPTIONAL_STAGE3A_FIELDS = [
    "generator",
    "model_family",
    "model_id",
    "prompt",
    "seed",
    "width",
    "height",
    "generation_params_json",
]
MANIFEST_FIELDS = REQUIRED_FIELDS + OPTIONAL_STAGE3A_FIELDS
MANIFEST_FRAGMENT_GLOB = "*_manifest.csv"
GENERATOR_SPECS_BY_SOURCE = {
    spec.source: spec for spec in APPROVED_GENERATORS.values()
}


def _normalise_row(row: dict[str, str]) -> dict[str, str]:
    """Return a manifest row with all Stage 3A optional fields present."""
    normalised = dict(row)
    source = normalised.get("source", "")
    spec = GENERATOR_SPECS_BY_SOURCE.get(source)
    if normalised.get("class") == "ai_generated" and spec is not None:
        if not normalised.get("generator"):
            normalised["generator"] = spec.generator
        if not normalised.get("model_family"):
            normalised["model_family"] = spec.model_family
        if not normalised.get("model_id"):
            normalised["model_id"] = spec.model_id

    for field in OPTIONAL_STAGE3A_FIELDS:
        if normalised.get(field) is None:
            normalised[field] = ""
        else:
            normalised[field] = str(normalised.get(field, ""))
    return normalised


def _discover_manifest_fragments(input_dirs: list[Path], out: Path) -> list[Path]:
    """Find manifest fragments below each input directory in stable order."""
    discovered: list[Path] = []
    out_resolved = out.resolve()
    for input_dir in input_dirs:
        if not input_dir.exists():
            raise FileNotFoundError(f"Input directory does not exist: {input_dir}")
        if not input_dir.is_dir():
            raise NotADirectoryError(f"Input path is not a directory: {input_dir}")
        for path in sorted(input_dir.rglob(MANIFEST_FRAGMENT_GLOB)):
            if path.resolve() == out_resolved:
                continue
            discovered.append(path)
    return discovered


def _unique_paths(paths: list[Path]) -> list[Path]:
    """Deduplicate paths while preserving caller/discovery order."""
    unique: list[Path] = []
    seen: set[Path] = set()
    for path in paths:
        resolved = path.resolve()
        if resolved in seen:
            continue
        seen.add(resolved)
        unique.append(path)
    return unique


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--inputs",
        type=Path,
        nargs="+",
        default=[],
        help="Explicit manifest fragments to merge",
    )
    parser.add_argument(
        "--input-dir",
        type=Path,
        action="append",
        default=[],
        help=(
            f"Directory to scan recursively for {MANIFEST_FRAGMENT_GLOB}; "
            "can be passed multiple times"
        ),
    )
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()

    input_paths = _unique_paths(
        [*args.inputs, *_discover_manifest_fragments(args.input_dir, args.out)]
    )
    if not input_paths:
        raise ValueError("Provide at least one --inputs file or --input-dir")

    rows: list[dict] = []
    for src in input_paths:
        if not src.exists():
            raise FileNotFoundError(f"Input manifest does not exist: {src}")
        with src.open() as fh:
            reader = csv.DictReader(fh)
            for r in reader:
                missing = [f for f in REQUIRED_FIELDS if not r.get(f)]
                if missing:
                    raise ValueError(
                        f"{src}: row missing required fields {missing}: {r}"
                    )
                rows.append(_normalise_row(r))

    # Detect duplicates by sha256 — important for license cleanliness AND
    # to avoid train/test leakage.
    seen: dict[str, str] = {}
    deduped: list[dict] = []
    for r in rows:
        sha = r["sha256"]
        if sha in seen:
            print(f"  dropping duplicate {r['path']} (matches {seen[sha]})")
            continue
        seen[sha] = r["path"]
        deduped.append(r)

    args.out.parent.mkdir(parents=True, exist_ok=True)
    # Write a stable schema first, then any extra legacy/source-specific fields.
    fieldnames = sorted({k for r in deduped for k in r.keys()})
    ordered = MANIFEST_FIELDS + [f for f in fieldnames if f not in MANIFEST_FIELDS]

    with args.out.open("w", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=ordered)
        writer.writeheader()
        writer.writerows(deduped)

    classes: dict[str, int] = {}
    for r in deduped:
        classes[r["class"]] = classes.get(r["class"], 0) + 1

    print(f"\nMaster manifest: {args.out}")
    print(f"  fragments: {len(input_paths)}")
    print(f"  total rows: {len(deduped)}")
    for cls, n in sorted(classes.items()):
        print(f"  {cls}: {n}")

    generators: dict[str, int] = {}
    for r in deduped:
        if r["class"] != "ai_generated":
            continue
        generator = r.get("generator") or r["source"]
        generators[generator] = generators.get(generator, 0) + 1
    for generator, n in sorted(generators.items()):
        print(f"  generator {generator}: {n}")


if __name__ == "__main__":
    main()