Profiling is a corpus-level Stage emitting scoped FeatureRecords over a fixed feature set, streaming and with the enricher registry recast away¶
S7 adds Profiling (CONTEXT): the corpus-level Stage that computes statistical and quality features (density, readability, Zipf, cross-document entity frequency, co-occurrence). Four design questions had non-obvious answers; they extend ADR-0004/0007 (the Capability + CPU-first-reference pattern) and mirror how ADR-0022/0023/0024 handled S4/S5/S6, with the concrete Profiling contract, the corpus-level multi-input seam, the fixed feature set, and the streaming discipline.
A FeatureRecord is a per-scope inter-Stage Record, not a side-car report¶
Profiling could write its features directly into the Quality Report and emit nothing on
the DAG. It does neither exclusively. FeatureRecord is its own versioned core contract
(SCHEMA_VERSION → 7), a subclass of Record — so it carries Provenance and
Classification like every other inter-Stage carrier, threads through the DAG as a Stage
output, is checkpointed/resumed uniformly, and exports to the corpus directly (the
feature set is an AI-ready artifact a downstream consumer reads, not merely a run log).
Why scoped, not two contracts. Profiling is corpus-level, so it produces features at
two granularities: per-document (density, readability, Zipf α, compression ratio,
structure — one record per parsed document) and cross-document (entity frequency,
co-occurrence, type consensus, source coverage — one record for the whole run). Rather
than two record types, a single FeatureRecord carries a FeatureScope tag
(DOCUMENT / CORPUS) and exactly one populated payload (DocumentFeatures /
CorpusFeatures). A consumer reads one exported stream and filters by scope; the DAG,
the checkpoint, and Export all treat one carrier. The scope/payload consistency is
contract-enforced (a DOCUMENT record must carry document features and a
document_record_id and no corpus payload; a CORPUS record the inverse), so a Provider
cannot emit a mis-scoped record. The single CORPUS record's Provenance is a synthetic
corpus-scope lineage (latence:corpus, never a real file) — it satisfies the required
Record Provenance while making the corpus scope unambiguous, and the Runner excludes it
from the run's source-document count.
Profiling is corpus-level and multi-input, off Parse (+ optionally Entity Extraction)¶
Profiling depends on Parse for the DocumentRecords — the per-document features and
the corpus text aggregates are computed over the document's assembled markdown at its
original-document offsets, the same coordinate system every other Stage uses, so it scans
the parsed document, not the markup-stripped chunk stream (the identical argument ADR-0024
made for Redaction). It optionally depends on Entity Extraction for the run's
EntityMentions — the cross-document entity frequency, type consensus, and co-occurrence
features. Unlike Relation Extraction (ADR-0023), the mention side is not required: a
Parse-only Profiling pipeline is valid and yields the corpus text/source-coverage features
with empty entity aggregates. The Runner partitions the gathered inputs by carrier and
errors only if a non-empty input carries no DocumentRecord (a mis-wired Parse edge).
One fixed, closed feature set — the 9-enricher registry is recast away¶
The predecessor stack computed features through a dynamic registry
of nine enrichers. That port was scoped as a recast, not a faithful re-home: the registry
is heavier than a fixed set needs, so the nine enrichers consolidate into one
document_features + corpus_features module. We port the
algorithms faithfully — density, a dependency-free Flesch-style readability normalised to
[0, 1], a least-squares Zipfian-α fit, a zlib compression ratio as a redundancy proxy,
plus the cross-document entity frequency / co-occurrence / type-consensus / source-coverage
aggregates — but drop the registry indirection. The feature set is a fixed, closed
Pydantic schema (DocumentFeatures / CorpusFeatures), not an open bag keyed by
plugin-registered names. This keeps the schema stable and auditable, keeps a seeded run
byte-identical (the Baseline bar), and removes a layer of configuration surface that bought
nothing for a fixed v1 feature set. A future feature is a schema addition (a version bump),
not a registry entry — a deliberate trade of extensibility for a stable, verifiable
contract, the same "framework owns the seam" stance as the rest of the spine.
The feature pass is streaming with bounded aggregate state, never whole-corpus-in-RAM¶
The S7 acceptance criterion requires the feature pass to make no whole-corpus-in-RAM
assumption — the predecessor stack had to spill features to disk under memory pressure,
and that motivation carries over to this Stage. So the engine's cross-document aggregation is a
streaming CorpusAggregator: documents are folded in one at a time and the corpus text
is never held whole — the reference Provider drives it from a generator over the document
stream, so text is processed and released document by document.
The aggregate state that is retained is explicitly bounded so it cannot be driven to unbounded growth by a hostile or pathological mention stream (an algorithmic-complexity memory-DoS lever that activates with a large NER Provider — the in-core gazetteer only latently bounds it today):
- the per-document co-occurrence fan-out is capped at
max_entities_per_documentdistinct entities, so oneadd_mentionscall isO(cap²)rather thanO(distinct-entities²)— the most-frequent entities in the document are kept (lexicographic tie-break); - the accumulated pair table is capped at
max_tracked_pairsentries — once full, a pair not already tracked is dropped (the finalised report is a capped top-K summary anyway).
To be precise about what is not bounded and why: the frequency/consensus counters and the corpus vocabulary grow with the corpus vocabulary (distinct words / distinct entity surfaces), which is inherent to the feature set — a per-vocabulary bound. Nothing spills to disk in the v1 reference engine; the earlier "spill-to-disk friendly" framing overclaimed a capability the code does not have. Disk-spilling of the per-vocabulary counters remains a post-v1 Provider option behind the same seam; the v1 guarantee is streaming corpus text plus the two hard caps above, not disk-spill.
The aggregation is commutative and every finalised map is sorted, so the result is order-independent and byte-identical for a seeded run regardless of document arrival order (the per-document cap keeps the most-frequent entities with lexicographic tie-breaks, so it too is order-stable).
Providers shipped (CPU-first reference, ADR-0007; no new model, no new license)¶
profiling.statisticalships inlatence-core: a pure-Python, dependency-free, deterministic corpus profiler wrapping the consolidated enricher engine (latence_core.profiling). It computes the full fixed feature set on CPU with no model download (no tokenizer, no embeddings — the Zipf fit and readability are closed-form), so firstgit clone→ working profiling is hash-stable for the Baseline bar. Profiling needs no learned model in v1, so — unlike S4/S5/S6 — there is no heavy Provider package and no new weights/code license to verify (ADR-0012); a future embedding-based feature (semantic density, topic coverage) would be a post-v1 Provider behind this sameProfilerseam.
The feature summary lands in the Quality Report as ProfilingQuality: per-document feature
averages (readability / density / diversity / Zipf / compression) plus the corpus
aggregates (word / vocabulary counts, corpus Zipf α, source coverage, entity-type
frequency, top co-occurrences) — counts and averages only, no raw document text — satisfying
the S7 "feature summary lands in the Quality Report" criterion. The top-co-occurrence entries
are keyed by a deterministic, salted sha256 pair fingerprint ("pair:<hex>") —
the same salted-digest construction S6 uses in placeholder_for, with the salt resolved from
the Redaction Stage config (default "latence") — not the raw left|right entity surfaces:
a co-occurring pair drawn from a confidential source must not have its (possibly PII) surface
printed into the internal-stamped report. The salt makes recovery per-deployment and defeats
precomputed rainbow tables, but this is a fingerprint, not encryption — entity surfaces are
low-entropy, so a reader who can enumerate candidate names and knows the salt can still recover
a pair by dictionary attack. It removes the raw surface from the report; it does not make
the report safe to publish to an untrusted audience (do not overstate this as "non-reversible").
The raw surfaces remain only in the exported, sensitivity-stamped CorpusFeatures data
artifact — the AI-ready data, not the shareable report.