Three candidate key schemas for the two largest measurement tables (friction_cycles, 1.44M rows and friction_loop_points, 2.88M rows) were built from the same simulated corpus and measured for disk footprint, load time and six read patterns. In every variant the relations between tables are single-column integer foreign keys (track_id, coupon_id); the variants differ only in how a bulk row is identified inside its own table.
+
+
+
Disk, variant C vs A
+74%
190.2 vs 109.1 MiB
+
GROUP BY scan, C vs A
+59%
135.9 vs 85.7 ms (Q5 pattern)
+
Enforced FK cost (B)
+0.5 s
load only; disk and reads = A
+
Point lookups
±2%
identical across A / B / C
+
+
+
+
The three variants
+
+
Variant A - composite natural PK. Row identity of a bulk row is its natural composite key (track_id, cycle), stored as a clustered WITHOUT ROWID table. FK columns are plain integers, validated by the loader against the corpus manifest (no engine constraint).
+
Variant B - composite natural PK + enforced FK. Same physical layout as A, plus REFERENCES constraints with PRAGMA foreign_keys=ON, so the engine itself guarantees referential integrity.
+
Variant C - surrogate key on every table. Classic style: each table gets a single-column auto-increment key named <singular>_id (friction_cycle_id, never a bare id). Correctness then requires an additional UNIQUE(track_id, cycle) index - a second B-tree.
+
+
Referential integrity is not what differs between the variants: every relation is a single integer FK in all three. What differs is the number of B-trees the engine must store and traverse per bulk table: one (A, B) or two (C).
+
+
+
+
+
+
Sample Dataset
+
All measurements ran against the real generated corpus of the evaluation pipeline - deterministic, physically plausible tribology data (seed 20260711). The two bulk tables and their parent dimension were loaded in full.
one position-resolved loop point (10 loops × 200 pts per track)
2,880,000
position_um, friction_force_mN
friction_loops.csv × 1,440
+
+
+
+
+
Corpus context
+
The corpus models a Pt-Au coating study: 4 deposition batches → 12 wafers → 588 coupons (480 friction-tested) → 1,440 tracks → 1.44M cycle records. Generated size: 141.8 MiB of CSV; the production target for extrapolation is 600 GB - 6 TB, which is why per-row bytes on the bulk tables matter.
+
+
+
+
+
+
Variant A · Composite Natural Primary Key baseline
+
The bulk row is identified by what it physically is: cycle n of track t. The composite PK doubles as the clustering order, so the table itself is the only B-tree. FK columns are unenforced integers; integrity is guaranteed by row-count and checksum validation against the corpus manifest.
+
+
+
+
+
+
tracksdimension
+
track_idPK
+
track_codeUK
+
coupon_idFK
+
run_idFK
+
environmenttext
+
load_mnreal
+
+
+
friction_cycles1.44M rows
+
track_idPK · ref
+
cyclePK
+
cofreal
+
+
+
friction_loop_points2.88M rows
+
track_idPK · ref
+
cyclePK
+
ptPK
+
position_umreal
+
friction_force_mnreal
+
+
+ wear and track_summary follow the same pattern: single-column PK = FK track_id (1:1 with tracks).
+ ref = integer reference to tracks(track_id), validated by the loader, no engine constraint.
+
+
+
CREATE TABLE friction_cycles (
+ track_id INTEGER NOT NULL, -- reference to tracks(track_id), loader-validated
+ cycle INTEGER NOT NULL,
+ cof REAL NOT NULL,
+ PRIMARY KEY (track_id, cycle)
+) WITHOUT ROWID; -- clustered: the PK is the table's only B-tree
+
+
+
+
+
+
Variant B · Composite PK + Enforced Foreign Keys recommended
+
Physically identical to Variant A - same clustered layout, same single B-tree per bulk table. The only change: FK columns carry real REFERENCES constraints and every connection runs PRAGMA foreign_keys=ON, so the DBMS itself rejects an orphan row. Measured cost: +0.5 s of load time on 4.32M rows; zero cost on disk and reads.
+
+
+
+
+
+
tracksdimension
+
track_idPK
+
track_codeUK
+
coupon_idFK
+
run_idFK
+
environmenttext
+
load_mnreal
+
+
+
friction_cycles1.44M rows
+
track_idPK
+
track_id → tracksFK ✓
+
cyclePK
+
cofreal
+
+
+
friction_loop_points2.88M rows
+
track_idPK
+
track_id → tracksFK ✓
+
cyclePK
+
ptPK
+
position_um, friction_force_mnreal
+
+
+ PRAGMA foreign_keys = ON on every connection - SQLite silently ignores FK clauses without it.
+ FK checks are lookups into the already-indexed parent PK, which is why the measured overhead is only ~6% of load time.
+
+
+
PRAGMA foreign_keys = ON;
+
+CREATE TABLE friction_cycles (
+ track_id INTEGER NOT NULL REFERENCES tracks(track_id),
+ cycle INTEGER NOT NULL,
+ cof REAL NOT NULL,
+ PRIMARY KEY (track_id, cycle)
+) WITHOUT ROWID;
+
+
+
+
+
+
Variant C · Surrogate <singular>_id on Every Table +74% disk
+
The uniform classic design: every table, including the bulk fact tables, gets a single-column auto-increment key (friction_cycle_id, friction_loop_point_id - by convention always <table name in singular>_id, never a bare id). In SQLite the id column itself is nearly free (it aliases the rowid), but correctness still demands UNIQUE(track_id, cycle) - and that unique index is a second B-tree holding a copy of almost every column.
+
+
+
+
+
+
tracksdimension
+
track_idPK
+
track_codeUK
+
coupon_idFK
+
run_idFK
+
environmenttext
+
load_mnreal
+
+
+
friction_cycles1.44M rows
+
friction_cycle_idPK
+
track_id → tracksFK ✓
+
cycleint
+
cofreal
+
UNIQUE (track_id, cycle)2nd B-tree
+
+
+
friction_loop_points2.88M rows
+
friction_loop_point_idPK
+
track_id → tracksFK ✓
+
cycleint
+
ptint
+
position_umreal
+
friction_force_mnreal
+
UNIQUE (track_id, cycle, pt)2nd B-tree
+
+
+ The surrogate key is never referenced by any other table - no row in the schema points at an individual cycle.
+ The UNIQUE index is mandatory for correctness (it prevents duplicate cycles), so its cost cannot be avoided in this variant.
+
+
+
CREATE TABLE friction_cycles (
+ friction_cycle_id INTEGER PRIMARY KEY, -- rowid alias; unused by any FK
+ track_id INTEGER NOT NULL REFERENCES tracks(track_id),
+ cycle INTEGER NOT NULL,
+ cof REAL NOT NULL,
+ UNIQUE (track_id, cycle)-- required; duplicates the table
+);
+
+
+
+
+
+
Benchmark Method
+
Identical protocol for all three variants; only the DDL differs.
+
+
+
Data: the full generated corpus subset - tracks (1,440), friction_cycles (1,440,000), friction_loop_points (2,880,000) - read from the same CSV files in the same order for every variant.
+
Load: SQLite, PRAGMA journal_mode=OFF, synchronous=OFF, cache_size=-262144; inserts via executemany in 50k-row batches; VACUUM before measuring the file size.
+
Reads: warm OS page cache, best of 3 runs per pattern; fixed deterministic probe sets (39 tracks for range reads, 150 (track_id, cycle) pairs for point lookups); result-set sizes asserted, so every variant answers the identical question.
+
Caveat: at 150 MB everything fits in RAM, which flatters variant C - at the 600 GB production target the working set exceeds RAM, and twice the pages means twice the cache pressure and disk reads. Warm-cache deltas are therefore a lower bound.
+
+
+
+
+
+
+
Results · Storage & Load
+
File size after VACUUM; load time includes CSV parsing (identical work in all variants).
+
+
+
+
Variant
Bulk-table keys
Load time (s)
DB size (MiB)
Size vs A
+
+
A · composite PK
PK (track_id, cycle), unenforced refs
7.5
109.1
baseline
+
B · composite PK + FK
PK (track_id, cycle), REFERENCES enforced
8.0
109.1
±0%
+
C · surrogate id
friction_cycle_id PK + UNIQUE(track_id, cycle)
8.8
190.2
+74%
+
+
+
+
+
+
Database size after VACUUM (MiB, scale 0-200)
+
+
Variant Acomposite PK, unenforced refs
109.1 MiB
+
Variant Bcomposite PK + enforced FK
109.1 MiB
+
Variant Csurrogate id + UNIQUE index
190.2 MiB
+
+
Load time, 4.32M rows (s, scale 0-10)
+
+
Variant A
7.5 s
+
Variant B
8.0 s
+
Variant C
8.8 s
+
+
Extrapolated to the 600 GB production target, the +74% of variant C on the two hottest tables translates into hundreds of gigabytes of extra disk and a doubled page count competing for the same RAM cache.
+
+
+
+
+
+
Results · Read Performance
+
Warm OS cache, best of 3 runs. Q1 / Q5 refer to the benchmark scenarios of the evaluation pipeline.
+
+
+
+
Read pattern
Unit
A
B
C
C vs A
+
+
Point lookup of one row by (track_id, cycle)
µs/op
24.8
23.6
24.3
~0%
+
Q1: all 1,000 cycles of one track
ms/track
0.473
0.485
0.533
+13%
+
All 2,000 loop points of one track
ms/track
1.267
1.299
1.336
+5%
+
Full scan + AVG over 1.44M cycles
ms
64.9
64.9
70.7
+9%
+
AVG with GROUP BY track_id over 1.44M cycles (Q5 pattern)
ms
85.7
89.1
135.9
+59%
+
Full scan over 2.88M loop points
ms
152.5
150.8
165.4
+8%
+
+
+
+
+
+
Relative read time, variant A = 100% (lower is better)
+
+ A · composite PK
+ B · + enforced FK
+ C · surrogate id + UNIQUE
+
+
+
+
+
+
+
+
Why It Differs
+
The whole difference reduces to one thing: how many B-trees per bulk table, and in what order the rows physically live.
+
+
+
+
+
+
Variants A / B · one B-tree
+
clustered table keyed by (track_id, cycle)
+
descent → leaf → sequential range scan
+
rows of one track are physically adjacent
+
+
+
Variant C · B-tree 1
+
UNIQUE index (track_id, cycle)
+
stores key columns + rowid again
+
+
+
Variant C · B-tree 2
+
table keyed by friction_cycle_id
+
per-row lookup to fetch cof
+
+
+
+
+74% disk. In SQLite the surrogate id column is a rowid alias and costs nothing in the table itself - but the mandatory UNIQUE(track_id, cycle) index is a full second B-tree whose entries contain almost every column of the row. Two trees instead of one on a 3-column table is close to a doubling.
+
+59% on grouped aggregation (the pipeline's forced-full-scan Q5). In A/B the table is clustered by track_id, so GROUP BY track_id streams the tree in group order. In C the engine walks the UNIQUE index for ordering and hops into the table B-tree for every cof value - the classic double access path.
+
+5-13% on ranges and plain scans. More pages (190 vs 109 MiB) means more page traversals and cell decoding, even fully cached.
+
~0% on point lookups. Tree depth is the same either way; a single-row probe does not expose the second tree.
+
PostgreSQL outlook. PG has no WITHOUT ROWID: the composite-PK table is a heap plus one PK index either way, but variant C still adds the id column to every heap tuple and the second index - expect +30-50% on the bulk tables, plus higher COPY-time FK validation cost.
+
+
Integrity is a constant across all variants. Every relation is a single-column integer FK. Choosing C buys naming uniformity, not more integrity - and the surrogate key it adds is never referenced by anything.
+
+
+
+
+
+
Recommendation
+
+
Variant B Composite natural PK on bulk tables + enforced foreign keys
+
+
Referential integrity is guaranteed by the DBMS itself at a measured cost of +0.5 s of load time and nothing else.
+
Disk footprint and read profile are identical to the leanest variant A: 109.1 MiB, single clustered B-tree per bulk table.
+
Dimension tables (batches, wafers, coupons, runs, tracks, instruments) keep their classic single-column surrogate keys - <singular>_id, never a bare id - because other tables reference their rows.
+
Variant C remains a valid design where fact rows must be individually addressable from outside (annotations, reprocessing links). No table in this schema references an individual cycle or loop point, so its cost (+74% disk, up to +59% on aggregation, growing at 600 GB scale) buys nothing here.
+
+
+
+ Measured 2026-07-11 on the deterministic simulated corpus (seed 20260711, 141.8 MiB CSV) · SQLite, warm OS cache, best of 3 runs ·
+ scripts: measure_key_variants.py (build + size + load + Q1) and read_benchmarks.py (six read patterns) ·
+ companion page: Tribology_Ontology.html (measurement-result registry).
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/rules/db-sql-schema.md b/docs/rules/db-sql-schema.md
index 55278cb..1c2fd4b 100644
--- a/docs/rules/db-sql-schema.md
+++ b/docs/rules/db-sql-schema.md
@@ -74,11 +74,14 @@ and it wins where a spec detail is ambiguous.
- Loads are all-or-nothing per table and validated against `MANIFEST.csv`
row counts - see [code-error-handling.md](code-error-handling.md)
section 3 and [test-pipeline-validation.md](test-pipeline-validation.md).
-- On every SQLite connection that relies on FK behaviour:
- `PRAGMA foreign_keys = ON` (SQLite silently ignores FK clauses without
- it). Bulk tables MAY declare FK columns without enforced constraints for
- load speed - row-count validation is the integrity gate; whichever choice
- is made must be the same in both engines.
+- **Foreign keys are ENFORCED in both engines on every table, including
+ bulk tables** (owner decision 2026-07-11, after measurement on the real
+ corpus: +0.5 s load on 4.3M rows in SQLite, zero disk and read cost;
+ full study with ER diagrams and measurements:
+ [docs/research/Tribology_FK_Architecture.html](../research/Tribology_FK_Architecture.html)).
+ Every SQLite connection sets `PRAGMA foreign_keys = ON` (SQLite silently
+ ignores FK clauses without it). Row-count validation against
+ `MANIFEST.csv` remains a second, independent integrity gate.
## 5. `track_summary` - precomputed, and off-limits to Q5
@@ -89,6 +92,20 @@ never read it** - Q5 is the forced full-scan benchmark over raw
`friction_cycles` in every format (spec
[08](../specs/08_benchmark_queries.md)).
+Binding computation (identical semantics in both engines; float results
+agree within the 1e-9 checksum tolerance):
+
+1. Steady-state proxy window = cycles 501-1000 (the second half);
+ `m` = avg(cof), `s` = stddev_pop(cof) over that window.
+2. `run_in_cycles` = the first cycle `c` with `|cof(c) - m| < 2*s`;
+ fallback 500 if no cycle qualifies.
+3. `cof_ss_mean` / `cof_ss_std` = avg / stddev_pop of cof over cycles
+ `> run_in_cycles`.
+
+SQLite computes this in Python during load
+([common/track_summary.py](../../common/track_summary.py)); PostgreSQL as
+a materialized view implementing the same three steps.
+
## 6. Engine parity
- Same logical schema, same table names, same column names, same row
diff --git a/docs/specs/05_convert_sqlite.md b/docs/specs/05_convert_sqlite.md
index 644a8fd..51c65ff 100644
--- a/docs/specs/05_convert_sqlite.md
+++ b/docs/specs/05_convert_sqlite.md
@@ -10,17 +10,19 @@ Build the single-file relational variant optimized for size and single-user quer
CSV tree.
## Processing instructions
-- One database `./out/sqlite/tribo.db`. PRAGMAs during load: `journal_mode=OFF, synchronous=OFF, cache_size=-262144`; after load: `journal_mode=WAL`, `ANALYZE`, `VACUUM`.
-- Schema (16-table style, integer surrogate keys everywhere; no TEXT foreign keys in bulk tables):
+- One database `./out/sqlite/tribo.db`. PRAGMAs during load: `journal_mode=OFF, synchronous=OFF, cache_size=-262144, foreign_keys=ON`; after load: `journal_mode=WAL`, `ANALYZE`, `VACUUM`.
+- Foreign keys are enforced on every table, including bulk tables (variant B of the measured key-schema study; owner decision 2026-07-11, see db-sql-schema rule). Full study with ER diagrams and measurements: [../research/Tribology_FK_Architecture.html](../research/Tribology_FK_Architecture.html).
+- Schema (16-table style, integer surrogate keys everywhere; no TEXT foreign keys in bulk tables; SQL column names are the lowercase of the CSV headers per data-naming-units):
- `batches, wafers, coupons, runs, instruments, tracks` - dimension tables, TEXT natural keys kept as unique columns.
- - `xrf_points(coupon_id INT, gx INT, gy INT, pt_wtpct REAL, au_wtpct REAL)`.
- - `profilometry_points(coupon_id INT, gx INT, gy INT, thickness_um REAL)`.
- - `nanoindentation(coupon_id INT, indent_id INT, x_um REAL, y_um REAL, hardness_gpa REAL, reduced_modulus_gpa REAL)`.
- - `afm(coupon_id INT PRIMARY KEY, ra_nm REAL, rq_nm REAL)`.
+ - `simtra_profiles(batch_id INT, row_no INT, angle_deg REAL, energy_ev REAL, pt_flux REAL, au_flux REAL, PRIMARY KEY(batch_id, row_no)) WITHOUT ROWID`.
+ - `xrf_points(coupon_id INT, grid_x INT, grid_y INT, pt_wtpct REAL, au_wtpct REAL, PRIMARY KEY(coupon_id, grid_x, grid_y)) WITHOUT ROWID`.
+ - `profilometry_points(coupon_id INT, grid_x INT, grid_y INT, thickness_um REAL, PRIMARY KEY(coupon_id, grid_x, grid_y)) WITHOUT ROWID`.
+ - `nanoindentation(coupon_id INT, indent_id INT, x_um REAL, y_um REAL, hardness_gpa REAL, reduced_modulus_gpa REAL, max_load_mn REAL, PRIMARY KEY(coupon_id, indent_id)) WITHOUT ROWID`.
+ - `afm(coupon_id INT PRIMARY KEY, ra_nm REAL, rq_nm REAL, image_file TEXT)`.
- `friction_cycles(track_id INT, cycle INT, cof REAL, PRIMARY KEY(track_id, cycle)) WITHOUT ROWID`.
- - `friction_loop_points(track_id INT, cycle INT, pt INT, position_um REAL, force_mn REAL, PRIMARY KEY(track_id, cycle, pt)) WITHOUT ROWID`.
+ - `friction_loop_points(track_id INT, cycle INT, pt INT, position_um REAL, friction_force_mn REAL, PRIMARY KEY(track_id, cycle, pt)) WITHOUT ROWID`.
- `wear(track_id INT PRIMARY KEY, wear_volume_um3 REAL, k_archard REAL, sliding_distance_m REAL)`.
- - `track_summary(track_id INT PRIMARY KEY, cof_ss_mean REAL, cof_ss_std REAL, run_in_cycles INT)` - precomputed during load.
+ - `track_summary(track_id INT PRIMARY KEY, cof_ss_mean REAL, cof_ss_std REAL, run_in_cycles INT)` - precomputed during load (algorithm fixed in db-sql-schema rule, section 5).
- Indexes for Q1-Q7: `coupons(au_wtpct_mean)`, `tracks(run_id)`, `tracks(coupon_id)`, `runs(environment)`, composite `tracks(environment, load_mn)` (denormalize environment/load onto tracks).
- Batch inserts with `executemany`, 50k rows per transaction.
- Validate row counts against MANIFEST.
diff --git a/generate_data.py b/generate_data.py
index 8fdb257..8713e3b 100644
--- a/generate_data.py
+++ b/generate_data.py
@@ -9,21 +9,26 @@ instruments would write it, driven entirely by out/config/lab_config.yaml
from __future__ import annotations
-import argparse
import hashlib
import logging
import math
import shutil
import sys
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
-import yaml
+
+from common.pipeline import (
+ check_dependencies,
+ load_lab_config,
+ parse_task_args,
+ remove_stale_marker,
+ write_marker,
+)
logger = logging.getLogger(__name__)
-REPO_ROOT = Path(__file__).resolve().parent
TASK_ID = "03"
DEPENDS_ON = ["01"]
@@ -361,56 +366,17 @@ class CorpusGenerator:
return len(data)
-def check_dependencies(out_root: Path) -> None:
- for dep in DEPENDS_ON:
- marker = out_root / ".done" / f"{dep}.ok"
- if not marker.exists():
- raise FileNotFoundError(f"dependency marker missing: {marker} - run task {dep} first")
-
-
-def write_marker(marker_path: Path, gen: CorpusGenerator, tree_bytes: int) -> None:
- lines = [
- f"task: '{TASK_ID}'",
- "status: ok",
- f"csv_root: {gen.csv_root.as_posix()}",
- f"files: {len(gen.manifest)}",
- f"total_rows: {gen.total_rows}",
- f"total_bytes: {tree_bytes}",
- f"coupons: {gen.counts['coupons']}",
- f"tracks: {gen.counts['tracks']}",
- f"cycle_rows: {gen.counts['cycle_rows']}",
- f"loop_points: {gen.counts['loop_rows']}",
- f"xrf_points: {gen.counts['xrf_rows']}",
- ]
- marker_path.parent.mkdir(parents=True, exist_ok=True)
- with open(marker_path, "w", encoding="utf-8", newline="\n") as fh:
- fh.write("\n".join(lines) + "\n")
-
-
def main() -> int:
- parser = argparse.ArgumentParser(description="Task 03: generate the CSV corpus")
- parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
- parser.add_argument("--out-root", type=Path, default=REPO_ROOT / "out", help="artifact tree root (default: ./out)")
- args = parser.parse_args()
- logging.basicConfig(
- level=args.log_level,
- stream=sys.stderr,
- format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
- )
-
+ args = parse_task_args("Task 03: generate the CSV corpus")
out_root: Path = args.out_root
csv_root = out_root / "csv"
- marker_path = out_root / ".done" / f"{TASK_ID}.ok"
try:
- check_dependencies(out_root)
- if marker_path.exists():
- logger.info("re-run: removing stale marker %s", marker_path)
- marker_path.unlink()
+ check_dependencies(out_root, DEPENDS_ON)
+ remove_stale_marker(out_root, TASK_ID)
if csv_root.exists():
logger.info("re-run: removing previous corpus %s", csv_root)
shutil.rmtree(csv_root)
- with open(out_root / "config" / "lab_config.yaml", encoding="utf-8") as fh:
- cfg = yaml.safe_load(fh)
+ cfg = load_lab_config(out_root)
gen = CorpusGenerator(cfg, csv_root)
gen.generate()
@@ -424,7 +390,17 @@ def main() -> int:
raise ValidationError(
f"tree size {tree_mib:.1f} MiB outside gate {SIZE_GATE_MIB[0]}-{SIZE_GATE_MIB[1]} MiB"
)
- write_marker(marker_path, gen, tree_bytes)
+ marker_path = write_marker(out_root, TASK_ID, {
+ "csv_root": gen.csv_root.as_posix(),
+ "files": len(gen.manifest),
+ "total_rows": gen.total_rows,
+ "total_bytes": tree_bytes,
+ "coupons": gen.counts["coupons"],
+ "tracks": gen.counts["tracks"],
+ "cycle_rows": gen.counts["cycle_rows"],
+ "loop_points": gen.counts["loop_rows"],
+ "xrf_points": gen.counts["xrf_rows"],
+ })
except Exception:
logger.critical("task %s failed", TASK_ID, exc_info=True)
return 1
diff --git a/make_lab_config.py b/make_lab_config.py
index 9805dff..1c04ec6 100644
--- a/make_lab_config.py
+++ b/make_lab_config.py
@@ -8,16 +8,16 @@ completion marker. Spec: docs/specs/01_lab_configuration.md.
from __future__ import annotations
-import argparse
import logging
import sys
from pathlib import Path
import yaml
+from common.pipeline import parse_task_args, remove_stale_marker, write_marker
+
logger = logging.getLogger(__name__)
-REPO_ROOT = Path(__file__).resolve().parent
TASK_ID = "01"
# Transcription of docs/specs/01_lab_configuration.md. This literal is the
@@ -301,51 +301,30 @@ def write_yaml(cfg: dict, config_path: Path) -> int:
return len(text.encode("utf-8"))
-def write_marker(marker_path: Path, config_path: Path, config_bytes: int, cfg: dict) -> None:
- v = cfg["volumes"]
- f = cfg["friction_assignment"]
- h = cfg["hierarchy"]
- lines = [
- f"task: '{TASK_ID}'",
- "status: ok",
- f"config_file: {config_path.as_posix()}",
- f"config_bytes: {config_bytes}",
- f"seed: {cfg['seed']}",
- f"batches: {h['batches']}",
- f"wafers: {h['batches'] * h['wafers_per_batch']}",
- f"coupons: {v['coupons_total']}",
- f"friction_coupons: {f['friction_coupons_total']}",
- f"tracks: {v['tracks_total']}",
- f"cycle_rows: {v['cycle_rows_total']}",
- f"loop_points: {v['loop_points_total']}",
- f"xrf_points: {v['xrf_points_total']}",
- ]
- marker_path.parent.mkdir(parents=True, exist_ok=True)
- with open(marker_path, "w", encoding="utf-8", newline="\n") as fh:
- fh.write("\n".join(lines) + "\n")
-
-
def main() -> int:
- parser = argparse.ArgumentParser(description="Task 01: write out/config/lab_config.yaml")
- parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
- parser.add_argument("--out-root", type=Path, default=REPO_ROOT / "out", help="artifact tree root (default: ./out)")
- args = parser.parse_args()
- logging.basicConfig(
- level=args.log_level,
- stream=sys.stderr,
- format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
- )
-
+ args = parse_task_args("Task 01: write out/config/lab_config.yaml")
out_root: Path = args.out_root
config_path = out_root / "config" / "lab_config.yaml"
- marker_path = out_root / ".done" / f"{TASK_ID}.ok"
try:
- if marker_path.exists():
- logger.info("re-run: removing stale marker %s", marker_path)
- marker_path.unlink()
+ remove_stale_marker(out_root, TASK_ID)
validate_config(LAB_CONFIG)
config_bytes = write_yaml(LAB_CONFIG, config_path)
- write_marker(marker_path, config_path, config_bytes, LAB_CONFIG)
+ v = LAB_CONFIG["volumes"]
+ f = LAB_CONFIG["friction_assignment"]
+ h = LAB_CONFIG["hierarchy"]
+ marker_path = write_marker(out_root, TASK_ID, {
+ "config_file": config_path.as_posix(),
+ "config_bytes": config_bytes,
+ "seed": LAB_CONFIG["seed"],
+ "batches": h["batches"],
+ "wafers": h["batches"] * h["wafers_per_batch"],
+ "coupons": v["coupons_total"],
+ "friction_coupons": f["friction_coupons_total"],
+ "tracks": v["tracks_total"],
+ "cycle_rows": v["cycle_rows_total"],
+ "loop_points": v["loop_points_total"],
+ "xrf_points": v["xrf_points_total"],
+ })
except Exception:
logger.critical("task %s failed", TASK_ID, exc_info=True)
return 1
diff --git a/make_process_flow.py b/make_process_flow.py
index 15b4bc5..9b1a466 100644
--- a/make_process_flow.py
+++ b/make_process_flow.py
@@ -8,16 +8,20 @@ the laboratory workflow: ./out/report/process_flow.md plus one standalone
from __future__ import annotations
-import argparse
import logging
import sys
from pathlib import Path
-import yaml
+from common.pipeline import (
+ check_dependencies,
+ load_lab_config,
+ parse_task_args,
+ remove_stale_marker,
+ write_marker,
+)
logger = logging.getLogger(__name__)
-REPO_ROOT = Path(__file__).resolve().parent
TASK_ID = "02"
DEPENDS_ON = ["01"]
@@ -190,13 +194,6 @@ def build_diagrams(cfg: dict) -> list[tuple[str, str, str, str]]:
]
-def check_dependencies(out_root: Path) -> None:
- for dep in DEPENDS_ON:
- marker = out_root / ".done" / f"{dep}.ok"
- if not marker.exists():
- raise FileNotFoundError(f"dependency marker missing: {marker} - run task {dep} first")
-
-
def validate_diagram(stem: str, text: str) -> None:
first = text.splitlines()[0].strip() if text.strip() else ""
if not first.startswith(DIAGRAM_TYPES):
@@ -235,42 +232,20 @@ def write_outputs(cfg: dict, out_root: Path) -> tuple[Path, int, int]:
return md_path, len(md_text.encode("utf-8")), total_diagram_bytes
-def write_marker(marker_path: Path, md_path: Path, md_bytes: int, diagram_bytes: int) -> None:
- lines = [
- f"task: '{TASK_ID}'",
- "status: ok",
- f"process_flow_file: {md_path.as_posix()}",
- f"process_flow_bytes: {md_bytes}",
- "diagrams: 6",
- f"diagram_bytes: {diagram_bytes}",
- ]
- marker_path.parent.mkdir(parents=True, exist_ok=True)
- with open(marker_path, "w", encoding="utf-8", newline="\n") as fh:
- fh.write("\n".join(lines) + "\n")
-
-
def main() -> int:
- parser = argparse.ArgumentParser(description="Task 02: write process flow diagrams")
- parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
- parser.add_argument("--out-root", type=Path, default=REPO_ROOT / "out", help="artifact tree root (default: ./out)")
- args = parser.parse_args()
- logging.basicConfig(
- level=args.log_level,
- stream=sys.stderr,
- format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
- )
-
+ args = parse_task_args("Task 02: write process flow diagrams")
out_root: Path = args.out_root
- marker_path = out_root / ".done" / f"{TASK_ID}.ok"
try:
- check_dependencies(out_root)
- if marker_path.exists():
- logger.info("re-run: removing stale marker %s", marker_path)
- marker_path.unlink()
- with open(out_root / "config" / "lab_config.yaml", encoding="utf-8") as fh:
- cfg = yaml.safe_load(fh)
+ check_dependencies(out_root, DEPENDS_ON)
+ remove_stale_marker(out_root, TASK_ID)
+ cfg = load_lab_config(out_root)
md_path, md_bytes, diagram_bytes = write_outputs(cfg, out_root)
- write_marker(marker_path, md_path, md_bytes, diagram_bytes)
+ marker_path = write_marker(out_root, TASK_ID, {
+ "process_flow_file": md_path.as_posix(),
+ "process_flow_bytes": md_bytes,
+ "diagrams": 6,
+ "diagram_bytes": diagram_bytes,
+ })
except Exception:
logger.critical("task %s failed", TASK_ID, exc_info=True)
return 1