# Overview

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.

Table | Grain | Rows loaded | Columns (beyond keys) | Source files
--- | --- | --- | --- | ---
tracks | one friction track (3 per tested coupon) | 1,440 | track_code, coupon_id, run_id, environment, load_mN, ... | track_info.csv × 1,440
friction_cycles | one reciprocating cycle of one track | 1,440,000 | cof | cof_vs_cycle.csv × 1,440
friction_loop_points | 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).