Skip to content

docs: apply RFC maturity-model amendments - #4

Merged
jensholdgaard merged 3 commits into
mainfrom
docs/maturity-model-amendments
Apr 28, 2026
Merged

docs: apply RFC maturity-model amendments#4
jensholdgaard merged 3 commits into
mainfrom
docs/maturity-model-amendments

Conversation

@jensholdgaard

Copy link
Copy Markdown
Owner

Replaces #2, which was auto-closed when its base branch (docs/verification-spec) was deleted as part of merging #1. Same head branch (docs/maturity-model-amendments), now targeting main directly.

Summary

Applies the amendments proposed at the bottom of docs/verification.md (landed in #1).

docs/rfcs/README.md

  • Required frontmatter: status: value list expanded to drafted | specified | red | green | validated | accepted | rejected | superseded.
  • Required sections: new §5 Acceptance criteria inserted between §4 Alternatives considered and the old §5 Testing strategy; Testing strategy → §6, Open questions → §7, References → §8.
  • Lifecycle: rewritten as the five-stage maturity model with superseded and rejected as terminals reachable from any stage.

CLAUDE.md

  • New §5.6 Verification process under Development workflow — three-line cross-reference pointing at docs/verification.md and the rule "if a criterion cannot be turned into a test, the RFC has a gap."

docs/rfcs/0001-template-miner.md, docs/rfcs/0002-query-dsl.md

  • status: draftstatus: drafted, applying the renamed maturity stage. No body changes.

Invariants and hazards touched

The amendments modify the process contract for handling §3 invariants and H-x hazards (RFCs now require §5 Acceptance criteria mapping each touched invariant/hazard to a numbered scenario). The §3 invariants themselves are unchanged. No hazard mitigation is weakened.

RFC 0001's body does not yet include §5 Acceptance criteria — its scenarios for CLAUDE.md §3.1 + hazards.md H1 land in a follow-up PR; the worked example in docs/verification.md §6 is the target shape.

Test plan

  • mdbook build — clean
  • CI green on main as new base
  • Reviewer confirms cross-references between docs/verification.md, docs/rfcs/README.md, and CLAUDE.md §5.6 round-trip correctly

🤖 Generated with Claude Code

jensholdgaard and others added 3 commits April 28, 2026 12:40
Describes the path from a CLAUDE.md §3 invariant or hazards.md H-x
hazard to a passing test: scenarios in RFC §5, scenario id grammar
and greppability contract, the five-stage RFC maturity model
(drafted/specified/red/green/validated) with the Specified gate as
the most valuable, BDD/ATDD outer loop with Beck-style TDD as the
recommended inner loop, regression handling after Validated, and a
worked example tracing CLAUDE.md §3.1 / hazards.md H1 through to
benchmarks.md C2 against RFC 0001.

The proposed amendments at the bottom of the file (rfcs/README.md
and CLAUDE.md §5.6) land in a separate PR.

SUMMARY.md gains a Verification entry under Architecture so the doc
is reachable from the mdbook nav as soon as it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applies the amendments proposed at the bottom of docs/verification.md
(landed in #1).

docs/rfcs/README.md
- status: value list expanded to drafted | specified | red | green |
  validated | accepted | rejected | superseded.
- New §5 Acceptance criteria in Required sections, with §5 Testing
  strategy → §6, Open questions → §7, References → §8.
- Lifecycle rewritten as the five-stage maturity model with superseded
  and rejected as terminals reachable from any stage.

CLAUDE.md
- New §5.6 Verification process — three-line cross-reference to the
  spec.

docs/rfcs/0001-template-miner.md, docs/rfcs/0002-query-dsl.md
- status: draft → drafted, applying the renamed maturity stage. No
  body changes; RFC 0001 picks up its §5 Acceptance criteria in a
  follow-up PR per docs/verification.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jensholdgaard
jensholdgaard merged commit 9161557 into main Apr 28, 2026
7 checks passed
@jensholdgaard
jensholdgaard deleted the docs/maturity-model-amendments branch April 28, 2026 14:04
jensholdgaard added a commit that referenced this pull request May 10, 2026
Two §5 invariant scenarios go from #[ignore] + todo!() to real
assertions in the same commit, jumping the §5 count from 4/29
to 6/29. Pattern matches PR #11 (MinerConfig flipped 3 stubs).

This PR ships the multi-tenancy *shape* — TenantId, MinerCluster
with one TenantState per tenant, lazy per-tenant allocation —
but explicitly NOT the Drain tree. Per-tenant state is a
HashMap<Vec<String>, u64> keyed on the masked-token sequence
(exact-match templating). Future PRs replace the HashMap with
simSeq + the depth-bounded tree + widening (RFC 0001 §6.2 steps
3–5). The §3.7 isolation invariant is testable at this layer
because isolation is about *who owns which store*, not about how
the store clusters.

Implementation:

ourios-core::tenant::TenantId
  String-backed newtype (deferred u64 representation per the
  plan's design call #1: operator-facing slugs / UUIDs are
  String-shaped; column-store efficiency is a downstream concern
  the future ourios-parquet RFC will own). No validation yet —
  accept any string, future try_new can layer on top.
  AsRef<str> + Display so it composes with log lines + metric
  labels without ceremony.

ourios-miner::cluster::MinerCluster
  Public type holding a HashMap<TenantId, TenantState> and a
  *cluster-wide* template_id allocator. Tenant state allocated
  lazily on first ingest. Public API: new, config, ingest,
  template_count, templates_for. The latter two are test
  helpers but kept public per the plan's design call #4 (future
  operator-console-style tooling will want them; pub(crate)
  tightening is easy if we change our minds).

ourios-miner::cluster::TenantState
  Private struct holding only the templates HashMap. Future
  PRs swap this for the real tree.

Why the template_id allocator is cluster-wide, not per-tenant:

RFC 0001 §6.1 uses the phrase "per-tenant monotonic" but ALSO
requires that "two tenants emitting the structurally identical
template will have different template_ids", and §5 §3.7.2
requires "no template_id is shared across tenants." A truly
per-tenant allocator gives both tenants id=1 for their first
template and silently violates §3.7.2 (the test caught this on
first run — id_a == id_b == 1). Reconciliation: the id *space*
is cluster-wide, but each tenant's slice of that space is
monotonic with respect to that tenant's allocation order. Both
phrases hold:

  - "per-tenant monotonic" — given a tenant, the sequence of ids
    allocated *to* that tenant strictly increases over time
  - "different template_ids across tenants" — the shared
    allocator never hands out the same id twice

A code comment on next_template_id documents this so future
readers don't try to "fix" the cluster-wide allocator back into
per-tenant.

Tests:

§5 stub flips (AAA-structured per the new policy):

- §3.7.1 — Two tenants emit different shapes; interleaved
  ingest. Asserts on token-set membership: A's tree contains
  A-shape tokens, B's contains B-shape tokens, neither contains
  the other's. Cross-pollination would mean either set
  contained tokens that originated in the other tenant's input.
- §3.7.2 — Two tenants emit the structurally identical line.
  Asserts id_a != id_b (the bug the cluster-wide allocator
  fixes).

Cluster unit tests (in cluster.rs, AAA-structured):

- ingest_returns_same_template_id_for_repeat_shape — exact-
  match templating gives one id for "user 42 logged in" and
  "user 17 logged in" since both mask to the same shape.
- ingest_returns_distinct_template_ids_for_distinct_shapes —
  same tenant, different shapes → different ids.
- template_count_is_zero_for_unseen_tenant — unseen tenants
  return 0 / [], no panic.
- ingest_lazily_allocates_per_tenant_state — first ingest
  materialises the tenant state; before that, count is 0.

Cargo dependency change: ourios-miner promotes ourios-core from
[dev-dependencies] to [dependencies] — the cluster module now
imports ourios_core::config::MinerConfig and ourios_core::
tenant::TenantId from non-test code. The single dep entry covers
both production code and the integration tests in
tests/invariants.rs.

Lifecycle (per docs/verification.md §3 two-loop spec):

- Outer loop (cargo test --all-features):
    21 passed (was 15: + 4 cluster unit tests + 2 newly green
    §5 scenarios), 23 ignored (was 25, − 2 flipped)
- Inner loop (cargo test --no-fail-fast -- --ignored):
    23 failed, was 25
- §5 scenario count toward Green: 4/29 → 6/29

RFC 0001 stays at status: red (23 stubs to go).

What this PR is NOT:

- Not Drain — no simSeq, no depth-bounded tree, no widening.
  Future PR.
- No audit events, telemetry, body retention, lossy_flag.
  Future PR(s).
- No Parquet record emission. ourios-parquet's problem.
- No tenant lifecycle (TenantPaused, TenantDeleted, eviction).
  RFC §9 deferral, future PR.

Verification (CLAUDE.md §6.6): cargo fmt clean, cargo clippy
clean (-D warnings, --all-targets --all-features), cargo test
passing (21 / 23 split), mdbook build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request May 10, 2026
* feat(cluster): add MinerCluster — flips §3.7.1, §3.7.2

Two §5 invariant scenarios go from #[ignore] + todo!() to real
assertions in the same commit, jumping the §5 count from 4/29
to 6/29. Pattern matches PR #11 (MinerConfig flipped 3 stubs).

This PR ships the multi-tenancy *shape* — TenantId, MinerCluster
with one TenantState per tenant, lazy per-tenant allocation —
but explicitly NOT the Drain tree. Per-tenant state is a
HashMap<Vec<String>, u64> keyed on the masked-token sequence
(exact-match templating). Future PRs replace the HashMap with
simSeq + the depth-bounded tree + widening (RFC 0001 §6.2 steps
3–5). The §3.7 isolation invariant is testable at this layer
because isolation is about *who owns which store*, not about how
the store clusters.

Implementation:

ourios-core::tenant::TenantId
  String-backed newtype (deferred u64 representation per the
  plan's design call #1: operator-facing slugs / UUIDs are
  String-shaped; column-store efficiency is a downstream concern
  the future ourios-parquet RFC will own). No validation yet —
  accept any string, future try_new can layer on top.
  AsRef<str> + Display so it composes with log lines + metric
  labels without ceremony.

ourios-miner::cluster::MinerCluster
  Public type holding a HashMap<TenantId, TenantState> and a
  *cluster-wide* template_id allocator. Tenant state allocated
  lazily on first ingest. Public API: new, config, ingest,
  template_count, templates_for. The latter two are test
  helpers but kept public per the plan's design call #4 (future
  operator-console-style tooling will want them; pub(crate)
  tightening is easy if we change our minds).

ourios-miner::cluster::TenantState
  Private struct holding only the templates HashMap. Future
  PRs swap this for the real tree.

Why the template_id allocator is cluster-wide, not per-tenant:

RFC 0001 §6.1 uses the phrase "per-tenant monotonic" but ALSO
requires that "two tenants emitting the structurally identical
template will have different template_ids", and §5 §3.7.2
requires "no template_id is shared across tenants." A truly
per-tenant allocator gives both tenants id=1 for their first
template and silently violates §3.7.2 (the test caught this on
first run — id_a == id_b == 1). Reconciliation: the id *space*
is cluster-wide, but each tenant's slice of that space is
monotonic with respect to that tenant's allocation order. Both
phrases hold:

  - "per-tenant monotonic" — given a tenant, the sequence of ids
    allocated *to* that tenant strictly increases over time
  - "different template_ids across tenants" — the shared
    allocator never hands out the same id twice

A code comment on next_template_id documents this so future
readers don't try to "fix" the cluster-wide allocator back into
per-tenant.

Tests:

§5 stub flips (AAA-structured per the new policy):

- §3.7.1 — Two tenants emit different shapes; interleaved
  ingest. Asserts on token-set membership: A's tree contains
  A-shape tokens, B's contains B-shape tokens, neither contains
  the other's. Cross-pollination would mean either set
  contained tokens that originated in the other tenant's input.
- §3.7.2 — Two tenants emit the structurally identical line.
  Asserts id_a != id_b (the bug the cluster-wide allocator
  fixes).

Cluster unit tests (in cluster.rs, AAA-structured):

- ingest_returns_same_template_id_for_repeat_shape — exact-
  match templating gives one id for "user 42 logged in" and
  "user 17 logged in" since both mask to the same shape.
- ingest_returns_distinct_template_ids_for_distinct_shapes —
  same tenant, different shapes → different ids.
- template_count_is_zero_for_unseen_tenant — unseen tenants
  return 0 / [], no panic.
- ingest_lazily_allocates_per_tenant_state — first ingest
  materialises the tenant state; before that, count is 0.

Cargo dependency change: ourios-miner promotes ourios-core from
[dev-dependencies] to [dependencies] — the cluster module now
imports ourios_core::config::MinerConfig and ourios_core::
tenant::TenantId from non-test code. The single dep entry covers
both production code and the integration tests in
tests/invariants.rs.

Lifecycle (per docs/verification.md §3 two-loop spec):

- Outer loop (cargo test --all-features):
    21 passed (was 15: + 4 cluster unit tests + 2 newly green
    §5 scenarios), 23 ignored (was 25, − 2 flipped)
- Inner loop (cargo test --no-fail-fast -- --ignored):
    23 failed, was 25
- §5 scenario count toward Green: 4/29 → 6/29

RFC 0001 stays at status: red (23 stubs to go).

What this PR is NOT:

- Not Drain — no simSeq, no depth-bounded tree, no widening.
  Future PR.
- No audit events, telemetry, body retention, lossy_flag.
  Future PR(s).
- No Parquet record emission. ourios-parquet's problem.
- No tenant lifecycle (TenantPaused, TenantDeleted, eviction).
  RFC §9 deferral, future PR.

Verification (CLAUDE.md §6.6): cargo fmt clean, cargo clippy
clean (-D warnings, --all-targets --all-features), cargo test
passing (21 / 23 split), mdbook build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(cluster): address PR #13 review — fix per-tenant-allocator drift

Two Copilot hits — both real doc/comment drift caused by my
mid-execution switch from per-tenant to cluster-wide template_id
allocator. The implementation went cluster-wide (rightly, per
team verdict + PR #14's RFC clarification) but two prose
artefacts kept describing the old per-tenant rationale:

- C1 (cluster.rs module docs): said "no shared template_id
  allocator" and "RFC 0001 §6.1's per-tenant monotonic
  template_id falls out of construction." The cluster-wide
  next_template_id field directly contradicts both clauses.
  Rewrite the opening paragraph to say what the code actually
  does: per-tenant template *stores* are isolated (no template
  ever crosses tenants), but the template_id allocator is
  cluster-wide so the same u64 never refers to two leaves; each
  tenant sees a monotonic *subsequence* of the shared id space.

- C2 (invariants.rs §3.7.2 test assertion comment): said
  "RFC 0001 §6.1's per-tenant template_id allocator gives each
  tenant its own monotonic id space, so the two ids are distinct
  by construction (each starts at 1)." This rationale is wrong
  for the cluster-wide allocator — the ids are distinct because
  the allocator never reuses values (id_a = 1, id_b = 2),
  not because each tenant has its own space starting at 1.
  Replace with the correct rationale: the second call pulls the
  *next* monotonic id rather than reusing the first tenant's id.

Both fixes are pure prose. No behaviour change. The tests
themselves still pass (the assertion logic was correct; only the
explanatory comment was stale).

Verification (CLAUDE.md §6.6): cargo fmt clean, cargo clippy
clean, cargo test passing (21 / 23 split unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 2, 2026
…emo) (#92)

* feat(bench): add B2 query-latency criterion bench (synthetic + otel-demo)

Supportive, non-gating wall-clock evidence for the RFC0007.2 thesis
gate that ourios-querier already proves structurally. New criterion
target `crates/ourios-bench/benches/b2.rs` with two groups:

- `b2/synthetic` (always runs): result size held constant
  (TARGET_ROWS of one template) while the corpus scales 1x/10x/50x
  with filler under distinct templates in separate files. Isolates
  the B2 variable — latency vs corpus at fixed result.
- `b2/otel-demo` (runs when OURIOS_B2_CORPUS_DIRS is set, a
  comma-separated list of corpus dirs): loads → mines → writes each
  real corpus to a temp Parquet store and times a query for the
  busiest template. Skipped when unset (the corpora aren't
  committed; CI / an operator stages them).

To query a real corpus the bench needs the mined records as a
queryable store, so `ourios_bench::build_query_store` (new public
`src/store.rs`) reuses the existing corpus loader + miner harness
(the same pipeline A1 measures) and writes per-partition Parquet.

criterion + ourios-querier (DataFusion) + a current-thread tokio
runtime are dev-deps under the `[[bench]]` target, so the bench
binary stays out of the A1/C1/C2 harness.

Indicative finding (laptop, not §1 baseline, so not recorded in §9):
synthetic latency is sub-linear in corpus (50x corpus -> ~4.6x
latency at constant result) — the residual is per-file footer/
metadata reads (file count scales with corpus), not data scanning
(which the structural B2 test proves flat). That points at the
small-file/compaction hazard (§4 #4), not a thesis failure.

benchmarks.md B2 section documents the instrument + how to run it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fixup! feat(bench): add B2 query-latency criterion bench (synthetic + otel-demo)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 7, 2026
Review pass 4 (internal consistency): drop drift from the §3.3 shared list
and principle #4 (it is deferred per §6.3; drift-alias is resolves_to);
fix the §6.2 hex wording (hex strings parsed case-insensitively, canonical
lowercase — was self-contradictory); scope severity_name to a severity RHS
in the §7 EBNF (split comparison into severity_cmp | scalar_cmp so
`service == error` is not grammatical); cite CLAUDE.md §4.6 in principle #2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 28, 2026
…TION_ENABLED

The binary always ran the compaction sweep, so a multi-pod deployment had every
receiver/querier/compactor pod sweeping (publish_cas-safe but redundant object
listing each interval). Add OURIOS_COMPACTION_ENABLED (default on; opt-out via a
falsey value, unlike the opt-in receiver/querier roles): when off, the process
skips building/running the compactor entirely, so a deployment can disable it on
receiver+querier pods and run a single dedicated compactor. Default-on preserves
existing behavior; the small-file hazard (#4) is unchanged for any deployment
that keeps a sweeper running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 28, 2026
… sweep (#303)

* feat(server): make a pod's compaction sweep opt-out via OURIOS_COMPACTION_ENABLED

The binary always ran the compaction sweep, so a multi-pod deployment had every
receiver/querier/compactor pod sweeping (publish_cas-safe but redundant object
listing each interval). Add OURIOS_COMPACTION_ENABLED (default on; opt-out via a
falsey value, unlike the opt-in receiver/querier roles): when off, the process
skips building/running the compactor entirely, so a deployment can disable it on
receiver+querier pods and run a single dedicated compactor. Default-on preserves
existing behavior; the small-file hazard (#4) is unchanged for any deployment
that keeps a sweeper running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(server): tighten compaction-disabled path per review

Per review: build the compactor via an explicit if (clone audit_store only when
enabled, don't move store into a never-run closure); skip interval parsing when
compaction is disabled so a disabled pod won't fail on an unused knob; and log
the disabled state at startup so it's visible in a multi-pod rollout. Tests:
disabled config tolerates a bad interval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(server): fix stale audit-sink comment in the compactor build

The slice-2d 'audit sink wired unconditionally' comment was left truncated and
contradicts the new compaction-enabled control flow; replace it with one comment
describing the conditional build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 28, 2026
…ons, curl test

Applies the CodeRabbit/Copilot review on the s3-native chart:

- merge per-role podAnnotations with the chart-level map (role wins) via a new
  ourios.podAnnotations helper, instead of one replacing the other; matches the
  podLabels behaviour (covers the duplicate receiver-line-36 thread).
- fail render when storage.backend=s3 has no storage.s3.bucket (required), and
  flip the default backend to local so a bare helm template/lint renders
  zero-config; s3 is documented as the production path.
- fail render when compactor.enabled=false (receiver/querier set
  OURIOS_COMPACTION_ENABLED=0, so the dedicated compactor is the only sweeper —
  hazard #4).
- fail render when both aws.existingSecret and the IRSA role-arn annotation are
  set (static keys would shadow web-identity creds).
- default image.tag to latest, not appVersion 0.0.0 (no image is published for
  the pre-release crate version); appVersion stays the version label.
- gate the NOTES credential warning on the specific eks.amazonaws.com/role-arn
  key, so unrelated ServiceAccount annotations no longer suppress it.
- switch the querier helm-test to curlimages/curl:8.11.1 (BusyBox wget lacks
  --post-data); curl -f asserts a 2xx. Receiver nc check unchanged.
- document local mode as single-node/RWX dev-only (no heavy guard).

Validated: helm lint; default(local) + s3 renders; s3-no-bucket,
compactor-off, and mixed-auth all fail with their messages; IRSA render has no
envFrom and no cred warning; podAnnotations merge confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 28, 2026
…ons, curl test

Applies the CodeRabbit/Copilot review on the s3-native chart:

- merge per-role podAnnotations with the chart-level map (role wins) via a new
  ourios.podAnnotations helper, instead of one replacing the other; matches the
  podLabels behaviour (covers the duplicate receiver-line-36 thread).
- fail render when storage.backend=s3 has no storage.s3.bucket (required), and
  flip the default backend to local so a bare helm template/lint renders
  zero-config; s3 is documented as the production path.
- fail render when compactor.enabled=false (receiver/querier set
  OURIOS_COMPACTION_ENABLED=0, so the dedicated compactor is the only sweeper —
  hazard #4).
- fail render when both aws.existingSecret and the IRSA role-arn annotation are
  set (static keys would shadow web-identity creds).
- default image.tag to latest, not appVersion 0.0.0 (no image is published for
  the pre-release crate version); appVersion stays the version label.
- gate the NOTES credential warning on the specific eks.amazonaws.com/role-arn
  key, so unrelated ServiceAccount annotations no longer suppress it.
- switch the querier helm-test to curlimages/curl:8.11.1 (BusyBox wget lacks
  --post-data); curl -f asserts a 2xx. Receiver nc check unchanged.
- document local mode as single-node/RWX dev-only (no heavy guard).

Validated: helm lint; default(local) + s3 renders; s3-no-bucket,
compactor-off, and mixed-auth all fail with their messages; IRSA render has no
envFrom and no cred warning; podAnnotations merge confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 29, 2026
* feat(helm): s3-native chart for the rfc 0019 split topology

Reworks the deploy/helm/ourios chart from the single local-PVC StatefulSet
into the RFC 0019 S3-native topology: a receiver StatefulSet with a
per-replica WAL PVC, a stateless querier Deployment that scales
independently and reads S3, and a singleton compactor Deployment. Only the
data/audit/manifest live on S3; the WAL is always a local durable PVC,
never S3 or emptyDir (CLAUDE.md §3.4/§3.6).

Credentials are never plaintext config: an existing Secret (envFrom) or
IRSA via serviceAccount annotations. A `local` backend remains as a
single-node/dev fallback with a shared data PVC.

The binary always runs the compaction role, so the dedicated compactor is
the designated sweeper while receiver/querier pods also sweep (safe via
publish-CAS); flagged in the README for review.

Validated with helm lint + helm template (default, s3, local, IRSA);
renders confirm the WAL is a volumeClaimTemplate, the querier has no WAL,
S3 env vars are set, and no plaintext AWS keys appear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(helm): only the dedicated compactor sweeps via OURIOS_COMPACTION_ENABLED

The receiver and querier workloads now set OURIOS_COMPACTION_ENABLED=0 (the new
binary flag), so a single dedicated compactor Deployment sweeps instead of every
pod. Move the sweep interval to a compactor-only env helper; rewrite the README
'Compactor topology' note (no more N+1 sweepers; replicas>1 safe-but-redundant
via publish-CAS, no leader election needed) and the NOTES warning for the
all-compaction-off misconfig. helm lint + template (default and s3) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): quote the test-pod query, correct interval + NOTES guidance

Review: run the helm-test querier probe via sh -c with single-quoted header/body
so the spaces survive; fix the values-table description for compactor.intervalSecs
(compactor-only, not every workload); and drop the bad NOTES advice to unset
OURIOS_COMPACTION_ENABLED via extraEnv (it would duplicate the env name) — point
at compactor.enabled=true instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): address pr #304 review — fail-fast guards, merged annotations, curl test

Applies the CodeRabbit/Copilot review on the s3-native chart:

- merge per-role podAnnotations with the chart-level map (role wins) via a new
  ourios.podAnnotations helper, instead of one replacing the other; matches the
  podLabels behaviour (covers the duplicate receiver-line-36 thread).
- fail render when storage.backend=s3 has no storage.s3.bucket (required), and
  flip the default backend to local so a bare helm template/lint renders
  zero-config; s3 is documented as the production path.
- fail render when compactor.enabled=false (receiver/querier set
  OURIOS_COMPACTION_ENABLED=0, so the dedicated compactor is the only sweeper —
  hazard #4).
- fail render when both aws.existingSecret and the IRSA role-arn annotation are
  set (static keys would shadow web-identity creds).
- default image.tag to latest, not appVersion 0.0.0 (no image is published for
  the pre-release crate version); appVersion stays the version label.
- gate the NOTES credential warning on the specific eks.amazonaws.com/role-arn
  key, so unrelated ServiceAccount annotations no longer suppress it.
- switch the querier helm-test to curlimages/curl:8.11.1 (BusyBox wget lacks
  --post-data); curl -f asserts a 2xx. Receiver nc check unchanged.
- document local mode as single-node/RWX dev-only (no heavy guard).

Validated: helm lint; default(local) + s3 renders; s3-no-bucket,
compactor-off, and mixed-auth all fail with their messages; IRSA render has no
envFrom and no cred warning; podAnnotations merge confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): cloud-agnostic framing + nil-safe IRSA, test-pod cleanup

The chart is S3-compatible, not AWS-specific. Reframe "S3-native"/"AWS"
throughout to "S3-compatible object storage" (AWS S3, MinIO, Cloudflare R2,
Hetzner, Ceph/RADOS, GCS S3-interop), and make non-AWS providers first-class:
storage.s3.endpoint is the knob for any S3-compatible store.

Restructure the credential surface to follow the backend, not the cloud:
fold credentials under storage.s3.existingSecret (was top-level aws.*), collapse
the duplicate region knob into storage.s3.region (drives both OURIOS_S3_REGION
and the SDK chain's AWS_DEFAULT_REGION), and rename the ourios.awsEnvFrom helper
to ourios.s3CredentialsEnvFrom. The AWS_* key names in the Secret are the S3 SDK
convention every S3-compatible provider uses (not AWS-the-cloud-specific); IRSA
stays clearly labeled as the AWS EKS-specific option.

Also carry the prior review fixes: nil-safe IRSA annotation lookup
(index ... | default dict), helm.sh/hook-delete-policy on the test pod, and the
curl-based query helm-test (BusyBox wget lacks --post-data).

helm lint + helm template verified for local, s3+endpoint+secret, the
mutual-exclusion fail, and the missing-bucket fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): use S3-named OURIOS_S3_* credential Secret keys; pin busybox test image

Now that the binary reads explicit OURIOS_S3_* credentials (RFC 0019 §3.4 /
#307), the chart's static-credential Secret documents the S3-named keys
(OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY [/ OURIOS_S3_SESSION_TOKEN])
instead of the AWS-SDK names — injected via envFrom, working with any
S3-compatible provider. IRSA stays the AWS-EKS option (AWS credential chain).

Also pins the receiver-only helm-test image to busybox:1.37.0 (a floating tag
could change nc behaviour and break helm test) — the last open #304 review point.

helm lint + template verified (envFrom secretRef renders; busybox pinned).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(helm): correct template/NOTES wording for the local default + cred chain

Address #304 review:
- receiver/querier headers said the store is 'S3 by default'; the chart defaults
  to local, so reword to 'local by default, S3 in production'.
- compactor header: it is compactor-only because it sets no receiver/querier env,
  not because those roles are unset (they can run in parallel).
- NOTES: the credential note now mentions the AWS credential-chain fallback
  (shared profile / node IAM / container creds), not just existingSecret/IRSA;
  the compactor-disabled line no longer asserts receiver/querier state the
  condition doesn't check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): fail render on an invalid backend; gate S3 creds on the s3 backend

Address #304 review:
- ourios.storageEnv now fails at render time unless storage.backend is exactly
  'local' or 's3' (a typo like 'S3' previously rendered and crashlooped).
- ourios.s3CredentialsEnvFrom is gated on backend==s3, so a stray
  storage.s3.existingSecret on the local backend is neither mounted nor
  cross-checked against IRSA.
- values: reword the storage.s3.region comment — region is optional (applied
  only when set), may be required depending on provider/SDK, and can also come
  from the standard AWS env/config.

helm template verified: bad backend fails; local ignores a stray secret+IRSA; s3
mounts the secret; s3 + secret + IRSA still fails (mutual exclusion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): fail render on non-positive querier window / compaction interval

Address #304 review: OURIOS_QUERIER_DEFAULT_WINDOW_SECS and
OURIOS_COMPACTION_INTERVAL_SECS must be positive integers or the server refuses
to start. Validate querier.defaultWindowSecs and compactor.intervalSecs at render
time (like the storage.backend guard) so a 0/negative value fails fast instead of
crashlooping the pod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(helm): add the k8s topology diagram (mingrammer diagrams)

Restores the Python diagrams (mingrammer) topology PNG for the chart, refreshed
to the current design: receiver/querier carry OURIOS_COMPACTION_ENABLED=0 (only
the dedicated compactor sweeps — no 'also sweeps' edges), the Secret holds the
S3-named OURIOS_S3_* keys, and the store is labelled 'object store (S3 API)'
(any S3-compatible provider). Source script committed alongside the PNG and
embedded in the README, with the ASCII block kept as a text fallback. docs/ is
.helmignore'd so the diagram isn't shipped in the chart package. This README is
outside the docs/ mdBook tree, so the §6.7 (Mermaid/SVG) conventions don't apply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): route diagram cred edges via workloads; ship PNG; guard IRSA SA

Address #304 review (diagram + IRSA edge cases):
- topology.py: credentials feed the workloads (envFrom/IRSA), which then
  read/write/sweep the store — not Secret/SA pointing at the store directly
  (correct trust boundary). PNG regenerated.
- .helmignore: ship docs/topology.png (so the README image resolves from the
  chart artifact / Artifact Hub), exclude only docs/*.py (the generator).
- README: chart-relative regen command; note IRSA requires serviceAccount.create=true.
- _helpers: fail render when an IRSA role-arn is set with serviceAccount.create=false
  (the chart renders no SA, so the annotation would silently have no effect).

helm lint + template verified: default + s3+IRSA render; s3+IRSA+create=false
fails; package ships the PNG and excludes the script.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): bound the helm-test pod (deadline + curl timeouts); clarify diagram source

Address #304 review:
- test-connection pod gets activeDeadlineSeconds: 60 so a stuck DNS/TCP connect
  fails helm test deterministically instead of hanging; the querier curl gets
  --connect-timeout 5 / --max-time 15 (the receiver nc already has -w5).
- README: note the topology.py source ships in the repo checkout only (excluded
  from the packaged chart via .helmignore), so the link resolves on GitHub, not
  from a chart artifact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(helm): test both roles when both are enabled (split topology)

Address #304 review: the test pod used if/else, so a default split-topology
install (receiver + querier both enabled) only checked the querier — a broken
receiver Service would pass helm test. Emit one container per enabled role
(independent ifs); the Pod succeeds only when every container exits 0. Verified:
both→query+otlp-http, querier-only→query, receiver-only→otlp-http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(helm): region is optional; querier reads the configured store (not just S3)

Address #304 review: the storageEnv header implied the region is needed for every
backend (it's optional, applied only when set, and can come from standard AWS
env/config), and the querier values comment said 'reads S3' though both local and
s3 backends are supported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 29, 2026
The OTLP receiver wired the template miner with a record sink but no
audit sink, so the miner's `template_created` / `template_widened` /
`template_type_expanded` events never reached the RFC 0005 audit Parquet
stream. The querier's read-time registry (RFC 0017
`derive_template_registry`) was therefore empty and `render_log_body`
fell back to the row's retained `body` — empty for clean, high-confidence
rows — so queries over freshly-ingested clean logs returned empty body
text, breaking `CLAUDE.md` §3.3.

Mirror the RFC 0014 record sink rather than wiring `ParquetAuditSink`
directly (which does a blocking per-event store write — request-path
stall + one tiny file per event, hazard #4):

- `BufferingAuditSink` / `SharedParquetAuditSink` (ourios-ingester):
  `emit` buffers cheaply on the request path; `flush` drains the buffer,
  groups events by audit partition, and writes each partition's batch
  with one `AuditWriter` (open_in → append_events → close) — few files,
  not one-per-event. A failed partition write retains its events (the WAL
  is the durability of record); an empty-buffer flush is a no-op.
- The receiver constructs the sink on the same `Store`, wires it via
  `MinerCluster::with_audit_sink(...).with_record_sink(...)` before
  recovery (so replay re-emits template events), and flushes it off the
  async runtime at the same cadence + rotation + shutdown points as the
  record sink — audit *before* records (durable no later than the rows it
  describes), with the snapshot gated on both sinks draining.
- Expose `derive_audit_partition` from ourios-parquet for the grouping.

Tests: unit tests for the buffering sink (per-partition batching round
trip + empty-buffer no-op); an in-process receiver test that ingests
clean logs, drains, derives the registry, and asserts every clean row
reconstructs `Faithful` from its template rather than empty. The RFC0019
`.3`/`.5` localstack scenarios now also assert the returned body text.
`rfc0013_6` scopes its data round-trip to `data/` so the new `audit/`
files aren't read with the data schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 29, 2026
…302)

Round 3 on #312 — close the empty-body window fully: a mined record must
never become query-visible before its template's audit event is durable,
under concurrency and the inline size trigger (strengthens `CLAUDE.md` §3.3
to hold on every publication path).

#4 — `emit` no longer blocks behind flush I/O. The audit sink's flush now
drains the buffer under the lock, releases it, does the `AuditWriter` store
I/O **unlocked**, then re-locks only to settle counters and requeue a
transient failure's events ahead of anything `emit` buffered meanwhile
(mirrors the record sink's documented "drain under the lock, I/O unlocked,
re-lock to settle"). A slow flush can't stall the request path.

#3 — the buffer is hard-bounded. The soft ceiling still signals an eager
off-runtime flush; a new hard cap (`AUDIT_SINK_MAX_EVENTS`, well above the
ceiling) is the OOM backstop: at the cap `emit` drops (counted via the new
`ourios.audit_sink.dropped` metric, logged once) rather than grow without
bound under sustained store-unavailability. Dropped template events degrade
those templates to retained/empty bodies until the WAL re-mines them on
restart — bounded memory is the deliberate trade.

#1/#2 — publication is audit-ordered and race-free via snapshot-then-
ordered-write. A new `PublishCoordinator` (ourios-ingester) drains both
sink buffers into owned batches under the pipeline's miner lock (a
microsecond memory move, no I/O — atomic w.r.t. `ingest`, closing the
cadence TOCTOU race), then writes off-lock: the audit batch to durability
first, the record partitions only after. A transient audit failure holds
the records (requeued, retried next cadence); a permanent audit failure
drops the audit batch and still publishes the records (the documented
degraded case). The receiver's age-sweep now publishes through it. The
inline size/ceiling trigger routes through a new record-sink audit barrier
(`ParquetRecordSink::with_audit_barrier`) that flushes the audit sink to
durability before the partition is put — race-free because that publish
runs under the miner lock. Rotation/shutdown already drain audit-before-
record under the miner lock (`flush_then_snapshot`), unchanged.

The record sink gains a drain/publish/requeue split (`drain_aged` /
`drain_all` / `requeue` / `publish_owned`) so the coordinator can move the
encode+put off the lock; its existing RFC 0014 emit/flush behavior and
tests are intact.

Tests: the coordinator holds records when the audit write fails transiently
(no data partition published though the data store is healthy); the size
trigger flushes audit-before-publish and is skipped when audit can't drain;
the hard cap drops + bounds; transient retains vs permanent drops; the
metrics export; plus all round-1/2 tests and the #302 regression stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jun 29, 2026
) (#312)

* fix(server): wire a buffering audit sink into the receiver (#302)

The OTLP receiver wired the template miner with a record sink but no
audit sink, so the miner's `template_created` / `template_widened` /
`template_type_expanded` events never reached the RFC 0005 audit Parquet
stream. The querier's read-time registry (RFC 0017
`derive_template_registry`) was therefore empty and `render_log_body`
fell back to the row's retained `body` — empty for clean, high-confidence
rows — so queries over freshly-ingested clean logs returned empty body
text, breaking `CLAUDE.md` §3.3.

Mirror the RFC 0014 record sink rather than wiring `ParquetAuditSink`
directly (which does a blocking per-event store write — request-path
stall + one tiny file per event, hazard #4):

- `BufferingAuditSink` / `SharedParquetAuditSink` (ourios-ingester):
  `emit` buffers cheaply on the request path; `flush` drains the buffer,
  groups events by audit partition, and writes each partition's batch
  with one `AuditWriter` (open_in → append_events → close) — few files,
  not one-per-event. A failed partition write retains its events (the WAL
  is the durability of record); an empty-buffer flush is a no-op.
- The receiver constructs the sink on the same `Store`, wires it via
  `MinerCluster::with_audit_sink(...).with_record_sink(...)` before
  recovery (so replay re-emits template events), and flushes it off the
  async runtime at the same cadence + rotation + shutdown points as the
  record sink — audit *before* records (durable no later than the rows it
  describes), with the snapshot gated on both sinks draining.
- Expose `derive_audit_partition` from ourios-parquet for the grouping.

Tests: unit tests for the buffering sink (per-partition batching round
trip + empty-buffer no-op); an in-process receiver test that ingests
clean logs, drains, derives the registry, and asserts every clean row
reconstructs `Faithful` from its template rather than empty. The RFC0019
`.3`/`.5` localstack scenarios now also assert the returned body text.
`rfc0013_6` scopes its data round-trip to `data/` so the new `audit/`
files aren't read with the data schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(parquet): drop intra-doc link to a private item

derive_audit_partition is now pub; its doc referenced the private
audit_partition_matches via an intra-doc link, which trips
rustdoc::private-intra-doc-links under cargo doc -D warnings. Use plain
backticks (the recurring private-item-doc convention).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(server): audit-sink metrics, error classification, bounded buffer (#302)

Address PR #312 review feedback on the receiver audit sink.

OTel metrics (§6.3): add `ourios.audit_sink.*` instruments mirroring the
record sink's `SinkMetrics` — `buffer.usage` (observable gauge of buffered
events), `flushes`, `flush.events`, `flush.errors` (split transient vs
permanent via the new `ourios.audit_sink.flush.outcome` attribute), and
`derive.errors`. Names go through the weaver registry
(`semconv/registry/{metrics,attributes}.yaml`); the generated
`ourios-semconv` constants are regenerated, not hand-written. Instruments
resolve through the global meter (no-op without a provider). A dedicated
test binary asserts the stream exports (separate process — `init_in_memory`
installs the global provider).

Data integrity: classify a failed partition flush. A store-`Io` error is
transient → retain + retry (the WAL is the durability of record); a
`Batch` / `Parquet` / `PartitionMismatch` / `Poisoned` error is permanent →
drop + count, so one malformed event can't requeue forever and wedge every
newer good event for that tenant/day behind it.

§3.3 flush gating: in both the age-sweep and `flush_then_snapshot`, flush
the audit sink first and skip the record flush this cycle if it didn't fully
drain — a non-empty buffer means a transient store error (permanents drop),
so the record flush to the same store would fail anyway, and flushing it
would expose a clean row before its template event is durable.

Bounded buffer: `emit` stays non-blocking but enforces a soft event ceiling
(default 100k); reaching it signals a `tokio::sync::Notify` the age-sweep
selects on, so adversarial template churn flushes promptly off the runtime
rather than growing the buffer until OOM. Signal-to-flush, never drop.

Tests: poison-pill (permanent drops + counts, does not requeue; transient
retains), flush-gating (record flush skipped while audit retains), bounding
(emit past the ceiling fires the notify), plus the metrics-export test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(server): audit-ordered publication, non-blocking flush, hard cap (#302)

Round 3 on #312 — close the empty-body window fully: a mined record must
never become query-visible before its template's audit event is durable,
under concurrency and the inline size trigger (strengthens `CLAUDE.md` §3.3
to hold on every publication path).

#4 — `emit` no longer blocks behind flush I/O. The audit sink's flush now
drains the buffer under the lock, releases it, does the `AuditWriter` store
I/O **unlocked**, then re-locks only to settle counters and requeue a
transient failure's events ahead of anything `emit` buffered meanwhile
(mirrors the record sink's documented "drain under the lock, I/O unlocked,
re-lock to settle"). A slow flush can't stall the request path.

#3 — the buffer is hard-bounded. The soft ceiling still signals an eager
off-runtime flush; a new hard cap (`AUDIT_SINK_MAX_EVENTS`, well above the
ceiling) is the OOM backstop: at the cap `emit` drops (counted via the new
`ourios.audit_sink.dropped` metric, logged once) rather than grow without
bound under sustained store-unavailability. Dropped template events degrade
those templates to retained/empty bodies until the WAL re-mines them on
restart — bounded memory is the deliberate trade.

#1/#2 — publication is audit-ordered and race-free via snapshot-then-
ordered-write. A new `PublishCoordinator` (ourios-ingester) drains both
sink buffers into owned batches under the pipeline's miner lock (a
microsecond memory move, no I/O — atomic w.r.t. `ingest`, closing the
cadence TOCTOU race), then writes off-lock: the audit batch to durability
first, the record partitions only after. A transient audit failure holds
the records (requeued, retried next cadence); a permanent audit failure
drops the audit batch and still publishes the records (the documented
degraded case). The receiver's age-sweep now publishes through it. The
inline size/ceiling trigger routes through a new record-sink audit barrier
(`ParquetRecordSink::with_audit_barrier`) that flushes the audit sink to
durability before the partition is put — race-free because that publish
runs under the miner lock. Rotation/shutdown already drain audit-before-
record under the miner lock (`flush_then_snapshot`), unchanged.

The record sink gains a drain/publish/requeue split (`drain_aged` /
`drain_all` / `requeue` / `publish_owned`) so the coordinator can move the
encode+put off the lock; its existing RFC 0014 emit/flush behavior and
tests are intact.

Tests: the coordinator holds records when the audit write fails transiently
(no data partition published though the data store is healthy); the size
trigger flushes audit-before-publish and is skipped when audit can't drain;
the hard cap drops + bounds; transient retains vs permanent drops; the
metrics export; plus all round-1/2 tests and the #302 regression stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ingester): keep requeued partitions aged for prompt retry

PublishCoordinator.requeue re-buffers a transient-failed batch ahead of records
emit added during the off-lock publish, but left PartitionBuffer.oldest at the
newer records' timestamp — so the already-aged requeued records could miss the
next age-sweep and retry late. Pin oldest to the age threshold (min with the
existing oldest) on requeue so the next sweep re-drains promptly. Test:
requeue_keeps_the_partition_aged_for_prompt_retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ingester): audit buffer retains, never drops (reverse the hard cap) (#302)

Round 5 on #312.

Item 1: reword the audit_sink module-doc typo "The Drain miner emits…" →
"The template miner emits…".

Item 2 (CodeRabbit Major — the round-2 hard-cap drop could lose data):
the hard cap dropped audit events at `AUDIT_SINK_MAX_EVENTS`, which is
unsafe. A dropped event isn't counted by `buffered_events()`, so the
no-loss snapshot gate (`flush_then_snapshot`) doesn't see it, the miner
snapshot advances past that line's WAL position, and on restart the
template event is never re-mined → those clean rows become permanently
unreconstructable (a §3.3 violation), not merely degraded-until-restart.

Adopt the record sink's posture (follow the reference, §5.4): under
sustained store-unavailability the audit buffer is RETAINED and may
transiently exceed the ceiling — never dropped. The WAL is the durability
of record; the snapshot gate prevents loss because it won't advance while
the buffer is non-empty.

- Delete `AUDIT_SINK_MAX_EVENTS` and the drop branch in `buffer_event`;
  `emit` always buffers. The soft ceiling still fires the `Notify` for an
  eager off-runtime flush — the bound for the realistic (healthy-store)
  case. `requeue_ahead` no longer caps/drops.
- Remove the `ourios.audit_sink.dropped` metric: reverted its
  `semconv/registry/metrics.yaml` entry, regenerated `ourios-semconv`
  (the const is gone; weaver no-diff verified), and dropped its use.
- Replace the `hard_cap_drops_and_bounds_the_buffer` test with
  `persistent_store_failure_retains_every_event_never_drops`: under a
  persistently failing store, repeated emit + flush retains every event
  (buffer grows past the ceiling) and drops nothing.
- Module docs state the posture explicitly (healthy store → ceiling +
  Notify bound it; sustained outage → retained, like the record sink; OOM
  under a total outage is the same accepted failure mode the record sink
  carries).

The transient-vs-permanent flush classification (Io retain / Batch +
Parquet + PartitionMismatch + Poisoned drop+count) is unchanged — that's
about un-writable content, unrelated to the memory bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 11, 2026
…flation) (#478)

* fix(bench): raise Loki's internal gRPC cap — single-line inflation (run #4)

Run #4 (29165198664) failed on the SAME ~5.27 MB internal message as
runs #2/#3 despite the outer cap halving (3 MiB → 1.5 MB), and the
fail-fast stayed silent — decisive: a single kafka LogsData line's
content alone inflates past Loki's stock 4 MiB internal gRPC cap. No
outer batching can split an indivisible unit.

Add -server.grpc-server-max-recv/send-msg-size=16 MiB to the indicative
run's documented ingest-side flags (standard operator tuning, in Loki's
favour — it lets Loki accept the data at all). This preserves the
identical-ingest precondition the equivalence check requires; skipping
the line would silently unequalize the two corpora.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bench): correct dskit flag names for the gRPC msg-size cap

Copilot caught that the flags are -server.grpc-max-recv/send-msg-size-
bytes (dskit's server registry, defaults exactly the 4 MiB we hit), not
-server.grpc-server-max-*. The wrong names would have failed Loki's
startup and burned run #5. Verified against dskit source. Also backtick
the kafka service name in the comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 15, 2026
…riants

PR review findings #2 and #4.

#2: `count [by …] | limit n` silently dropped the `limit` — execution
terminates in `Terminal::Aggregate`, which never consults `plan.limit`
(the aggregation map is the whole result; group-limiting semantics
aren't implemented). `validate()` now rejects the combination with a
clear QueryError::InvalidQuery instead of quietly returning the wrong
thing.

#4: pin detection (top-conjunctive `template_id == N`; `or`/`not`/
`resolves_to` don't pin), param-position duplication (at most one
`param(n)` per distinct n), and bucket constraints (positive width, at
most one `bucket(...)`) were covered only by hand-picked examples.
Adds a proptest generating arbitrary predicates and by-lists, checked
against an independently tracked ground truth (ground truth recorded
alongside generation, not derived from the code under test), covering
both `pinned_template_id` and `validate()`'s accept/reject decision.
The hand-picked examples stay as-is (CLAUDE.md §6.2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 15, 2026
…bucket(w) (#533)

* feat(querier): rfc 0002 green — count-by execution with param(n) and bucket(w)

The aggregation-execution slice of the RFC 0002 amendment 2026-07-15
(RFC 0031 L4): `count [by …]` now executes end-to-end, discharging §5
scenarios RFC0002.12/.13/.15/.16. RFC0002.14 (the grammar/compile error
contracts) stays an ignored red stub for its own slice.

Surface (§7 v1.1 / §6.4 amendment):
- IR: the aggregation `by`-list widens from `Vec<Field>` to
  `Vec<GroupTerm>` (field | `param(n)` | `bucket(duration)`).
- Parser: `group_list`/`group_term` productions, confined to `by`-lists;
  positive + negative parse tests per production.
- Structured surface: `{"param": n}` / `{"bucket": "<duration>"}`
  by-elements (widths validated by the string-DSL lexer, RFC0002.2);
  `structured_query.schema.json` gains the additive `group_term` def
  (snapshot-gated by RFC0002.11, which also gains instances).
- Serializer: group terms round-trip (corpus + proptest generator).

Compile (§6.3/§6.5 amendment):
- `compile::validate` lifts the `count` rejection ONLY — sum/min/max/avg,
  sort, project, render keep the explicit rejection. Enforces the
  single-template pinning rule for `param(n)` (top-conjunctive
  `template_id == N`, all naming one N; `resolves_to` does not pin),
  positive bucket widths, and the duplicate-term rules.
- Group terms lower as expressions inside the existing Aggregate row:
  `param(n)` = `array_element(params, n+1).value` (stored string form,
  no type promotion); `bucket(w)` = floor division of the effective
  timestamp (with the §3.9 `time_unix_nano` fallback) into half-open
  epoch-aligned UTC windows; `service` = the RFC 0022 promoted column.

Execute:
- One grouped-count scan per aggregation query (Filter → Aggregate,
  the drift precedent) with a row-level `tenant_id` guard mirroring
  drift's (§3.7 — group values are row contents). `rows` stays the
  total matching count, derived from the same scan.
- Short/NULL `param(n)` rows are EXCLUDED from every group (no synthetic
  absent key) and tallied on the new `QueryStats.rows_excluded`,
  surfaced on the RFC 0016 stats DTO (RFC0002.15).
- Result carrier: `QueryResult.aggregate: Option<Vec<AggregateGroup>>`
  (`key: Vec<String>` per by-term in query order — bucket keys RFC 3339
  UTC window starts — sorted, engine-free per hazard §4.6); the RFC 0016
  response gains the additive `aggregate` field so the HTTP surface
  cannot silently drop the map.
- RFC0002.16 honest bytes: the total is the group-column scan alone —
  zero row materialization, zero template-map acquisition (the RFC 0033
  acquisition was already lazy; the aggregation path never renders).

Invariants: hazard §4.6 (no DataFusion/arrow/SQL crosses the surface —
plain strings/ints only); §3.7 multi-tenancy (partition scope + the new
row-level tenant filter on the aggregation plan). Contract changes
sanctioned by the maintainer-merged amendment (#531): the `count` case
moves out of rfc0002_6_unsupported_stage_rejected (RFC0002.12 names the
lift), and rfc0005_14's error-precedence probe switches from `count` to
the still-rejected `render`.

Verified: cargo fmt --check; workspace clippy --all-targets
--all-features -D warnings; strict rustdoc (ourios-querier); full
cargo nextest run (1107 passed); .12/.13/.15/.16 force-run green,
.14 still ignored-failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(querier): structured by-list rejects resource/attr paths + param u32::MAX bound

PR review findings #1 and #3. The structured JSON surface's group_term
accepted {resource|attr} field objects in a by-list, but the string DSL's
group_term = field production (§7 v1.1) is bare-field-only — so the
structured surface could express count/aggregate-by queries the string
grammar cannot, which then failed in planning instead of at validation.

RawGroupTerm::into_ir now rejects a {resource|attr} object with a clean
DslError, and the schema gains a bare_field $defs entry so schema
validation itself rejects the shape instead of only the runtime
converter. The schema's param integer also gets an explicit maximum
(u32::MAX) so an out-of-range param slot fails schema validation
cleanly rather than succeeding the schema and then failing Rust
deserialization.

Adds schema instance-list cases (resource/attr group term, param past
u32::MAX) and a structured.rs unit test for the runtime rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(querier): reject count+limit; property-test the §6.3 planner invariants

PR review findings #2 and #4.

#2: `count [by …] | limit n` silently dropped the `limit` — execution
terminates in `Terminal::Aggregate`, which never consults `plan.limit`
(the aggregation map is the whole result; group-limiting semantics
aren't implemented). `validate()` now rejects the combination with a
clear QueryError::InvalidQuery instead of quietly returning the wrong
thing.

#4: pin detection (top-conjunctive `template_id == N`; `or`/`not`/
`resolves_to` don't pin), param-position duplication (at most one
`param(n)` per distinct n), and bucket constraints (positive width, at
most one `bucket(...)`) were covered only by hand-picked examples.
Adds a proptest generating arbitrary predicates and by-lists, checked
against an independently tracked ground truth (ground truth recorded
alongside generation, not derived from the code under test), covering
both `pinned_template_id` and `validate()`'s accept/reject decision.
The hand-picked examples stay as-is (CLAUDE.md §6.2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(querier): event_name in group-term generator; tenant + NULL-param regressions

PR review findings #5, #6, #7.

#5: the RFC0002.7 round-trip generator's `bare_field()` — shared by
`path_field()`, `group_term()`, and the `project` field list — omitted
`Field::EventName`, so grouped-query round-trips never covered
`count by event_name`. It's a valid bare field everywhere the real
grammar's `bare_field` (parse.rs) allows it, so fixed in place.

#6: `rfc0002_12_count_by_matches_naive_oracle`'s foreign-tenant fixture
is written via `write_all`, which partitions by the record's own
`tenant_id` — so the "b" row lands under tenant "b"'s own directory and
the row-level `tenant_id == tenant` backstop in `execute_aggregate`
(CLAUDE.md §3.7) is never exercised, only directory-level scoping. Adds
`rfc0002_12_aggregation_tenant_backstop_excludes_misplaced_row`, which
plants a tenant "b" row *inside* tenant "a"'s partition directory (the
shape a partitioning bug or on-disk corruption would produce — the
`ourios-parquet` writer's RFC 0005 §3.9 row-vs-path contract refuses a
mismatched tenant_id at write time, so the row is written honestly then
relocated) and asserts the backstop filter, not partitioning, keeps it
out of both the count and the group map. Manually verified this test
fails without the backstop filter, confirming it exercises the guard.

#7: RFC0002.15 covered a `params` list shorter than `n + 1`, but not the
distinct case of a list that HAS slot n whose own `value` decodes as
Parquet-level NULL (the field is nullable — RFC 0005 §3.2 — even though
`Param.value` is a non-`Option` Rust `String`, so only a raw/corrupted
writer can produce it). Adds
`rfc0002_15_present_but_null_param_slot_excluded_and_tallied`, built
with a raw arrow-array batch (mirroring `forward_compat.rs`'s
schema-drift fixtures) so the disposition is proven on the actual
`decode_aggregate` code path rather than assumed from the short-list
case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(querier): rfc 0002 — non_exhaustive QueryStats, typed group-null literal

QueryStats gains #[non_exhaustive] matching QueryResult's convention.
The rows_excluded doc comments now scope to any NULL group key, not
just param(n). The absent-OPTIONAL-column NULL substitute in the
aggregate group-term compiler now carries the field's real Arrow
type (Binary/Timestamp/FixedSizeBinary/Utf8) instead of always Utf8,
so the plan's output schema does not depend on which columns happen
to be present. Regression test covers grouping by an entirely-absent
FixedSizeBinary column (trace_id).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(server): rfc 0016 — skip the §7 default-limit injection for count-by queries

apply_limit ran unconditionally, but compile::validate now rejects
count+limit combined (RFC 0002 amendment 2026-07-15). Every
aggregation query sent to the HTTP endpoint was therefore a clean
400. Skip the injection when a Stage::Count is present. Regression
test confirmed via revert: fails without the fix, passes with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* docs(querier): rfc 0002 — execute_aggregate doc names the tenant backstop scan input

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(querier): rfc 0002 — checked_add for the excluded-row tally

Matches the existing pattern on rows: an overflow surfaces as an
error rather than silently wrapping in release builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(querier): rfc 0002 — mark AggregateGroup non_exhaustive

Matches QueryResult/QueryStats' convention for public response types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(querier): rfc 0002 — reject i64-overflowing bucket widths at validate time

bucket_expr's execution lowering casts the width to i64, but
validate_group_terms only checked positivity — a width between
i64::MAX and u64::MAX ns passed validation and failed later during
planning with a different error path. Moved into validate() for one
compile-time contract. Regression test confirmed via revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 15, 2026
Run #4's L4 pair plateaued at 11,053/11,523 rows across every 10s poll
instead of climbing to completeness. The picker's row ceiling (run #3's
fix) had already ruled out "too large to finish in time" — the count
never moved at all, which points at a cache serving the same stale
answer on every retry rather than a slow ingest.

Loki's bundled local-config.yaml enables the embedded results cache for
query_range's metric/matrix path (L4's loki_query_matrix), keyed by the
query+start+end+step tuple that loki_measure_frequency_pair repolls
unchanged. The first (still-incomplete) response gets cached and echoed
back on every subsequent poll. Plain log queries (loki_query_range, used
by L1-L3/L6) aren't extent-cached the same way, so they self-heal across
polls untouched by this.

-query-range.cache-results=false trades Loki's own query latency for
correctness of the harness's completeness poll — in Loki's favour, same
as the other operator-tuning flags already on this container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y
jensholdgaard added a commit that referenced this pull request Jul 15, 2026
Run #6 (with the corrected -querier.cache-results=false flag from the
prior commit) proved the results-cache theory wrong: L1-L3/L6 all
measured cleanly, but L4 still plateaued — 10752/11523 rows (93.3%),
even slightly worse than run #4's 95.9% pre-fix, and the shortfall
varies run to run rather than repeating a fixed cached answer.

That points at genuine, variable completion time rather than a bug:
L4's LogQL runs a `| regexp` capture over every candidate line before
grouping and counting, a real per-line cost the other classes' plain
stream/count queries never pay. Widened loki_measure_frequency_pair's
deadline from 300s to 900s — well inside the CI job's unset (360 min
default) timeout given the whole run has taken ~95-100 min so far.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y
jensholdgaard added a commit that referenced this pull request Jul 16, 2026
…line theory

Runs #4/#6/#7 all converged L4 to ~93-96% of expected rows, independent
of poll deadline (300s vs 900s made no measurable difference) — ruling
out both a results-cache echo (already disabled in #fe5915a) and a
"just needs more time" theory (the deadline widening from the prior
commit). A stable, time-independent shortfall points at something being
permanently excluded, not merely delayed.

Pulled the frozen otel-demo-v8 corpus locally and checked every log line
matching the L4 pair's needle ("Wrote producer snapshot at offset")
against its capture regex directly: all 11,525 matches parse cleanly.
The regex/content isn't the problem — some matching lines are never
being scanned at all.

That points at Loki's default -validation.max-entries-limit (5000):
count_over_time with a |regexp stage has to scan every raw kafka log
line in a query-frontend split before the line filter narrows it down,
and kafka's per-split volume exceeds 5000 lines often enough to
silently truncate the scan before every matching line is reached.
Raised the limit well past the corpus's noisiest single template's
volume (~971K rows).

Reverted the 900s deadline back to 300s (matching loki_measure_pair) —
the widened deadline never addressed the actual bottleneck, and keeping
it would misattribute the fix in a way that'd mislead the next reader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y
jensholdgaard added a commit that referenced this pull request Jul 16, 2026
…gone

Run #8 (max-entries-limit raised) moved L4 from a hard ~93% plateau to
97.1% (11192/11523) — real progress, and unlike runs #4/#6/#7 the
remaining gap now plausibly behaves like genuine ingest settle time
rather than a fixed ceiling, since the artificial cap that made the
prior 300s vs 900s test inconclusive is gone. Widened the deadline to
600s to test that directly before assuming a third factor is at play.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y
jensholdgaard added a commit that referenced this pull request Jul 17, 2026
* feat(bench): rfc 0031 l4 — wire into the live dispatch loop

Live-wires PairClass::L4 into rfc0031_indicative_comparative_run, the
#[ignore]d container-based dispatch test. The previous slice proved
the L4 machinery (ourios_aggregate_answer, parse_loki_matrix,
pick_frequency_pair, compare_aggregations) only at the fixture level,
against a hand-built Loki matrix response — this slice makes it real
against a running Loki container and the actual corpus.

L4 is picked and measured as its own step, kept OUT of the
`Picks`/`specs: Vec<PairSpec>` pipeline the L1/L2/L3/L6 classes share:
an aggregation's (bucket, group) -> count map is not a LineKey
multiset, and forcing it through OuriosAnswer/compare_lines would
misrepresent the state rather than model it (the same "make invalid
states unrepresentable" reasoning the miner/parquet layers already
follow). Concretely: pick_frequency_pair runs post-store-build like
pick_template_pair; its PairSpec is built with the exact dsl/logql
shape the fixture-level test already pinned; loki_query_matrix issues
a real query_range metric call with `step` pinned to the bucket
width so evaluation instants land on parse_loki_matrix's documented
bucket-alignment convention (t = bucket_start + width);
loki_measure_frequency_pair polls it to completeness the same way
loki_measure_pair does for line-returning pairs. Both share the same
Loki container and corpus replay as the existing pairs.

Equivalence-required-but-bytes-unasserted: RFC0031.1 (result-set
equivalence) is never optional, so run_l4_pair asserts
compare_aggregations(...).is_equal() unconditionally — an L4 mismatch
fails the run exactly like every other class's equivalence check. Only
the bytes RATIO stays unasserted (M_L4 is still §7-DEFERRED, no frozen
margin to gate against yet): print_l4_report reuses
print_pair_bytes_gates, which already prints L4's ratio with no
verdict. L4 is measured, equivalence-checked, and reported LAST — after
the L1-L3/L6 evidence has printed and their frozen gates have already
asserted — so an L4-only failure cannot destroy that evidence (the same
run #11 salvage lesson the rest of the harness follows). A missing
candidate is reported loudly at pick time, never silently skipped.

Purely additive: class_pair_specs, build_pair_specs, frozen_gate_failures,
print_pair_bytes_gates, print_indicative_report, PairSpec, and PairClass
are unchanged — no frozen-gate behavior for L1/L2/L3/L6 is touched.

Verification: cargo fmt --all --check, cargo clippy --all-targets
--all-features -- -D warnings (workspace), cargo nextest run -p
ourios-bench (165 passed, 7 skipped) and cargo test -p ourios-bench
--all-features all green, including the untouched fixture-level
rfc0031_5_l4_frequency_aggregation_bytes. The corpus-scale dispatch
test itself needs Docker + OURIOS_COMPARATIVE_CORPUS, neither available
in this sandbox — its first live proof is the comparative-bench
dispatch workflow, same as every other slice in this harness's history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — backtick-delimit the loki regexp argument

The dispatch's first-ever run failed: capture_regex's own Go RE2
escapes (\s+, \S+) were embedded inside a double-quoted LogQL string
literal, which tried to interpret those backslashes as its own escape
sequences (\s is not a valid one) and Loki rejected the query with
"invalid char escape" before the pattern reached the regex engine.
Fixed by switching to a backtick-delimited (LogQL/Go raw string)
regexp argument, which passes the pattern through literally.

Extracted the duplicated PairSpec-construction block (present
independently in the fixture test and the live-wiring loop) into one
shared l4_pair_spec helper, closing the drift risk and centralizing
the fix. Added a backtick guard: a capture_regex containing a
backtick (regex_escape does not escape backticks) would prematurely
close the raw string, so the candidate is now rejected loudly instead
of emitting a malformed query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — measure L4 before, not after, the L1-L6 failure asserts

The second dispatch run failed on the pre-existing, documented L3
Loki-side flake (0 of 9 rows before timeout) — but the run never even
attempted L4: the failures.is_empty() assert for L1-L6's own salvaged
measurement failures sat textually BEFORE the L4 measurement/report
code, so any earlier pair's failure aborted the test before L4 was
ever reached. This inverted the design intent (an L4-only failure
should not destroy L1-L6 evidence, not the other way around).

Moved L4's measurement to run immediately after the report prints,
before the gate/failures assertions. run_l4_pair now pushes a Loki-side
measurement failure (flake) into the same failures vec the other
classes salvage into, instead of panicking immediately — so a flaky
L4 measurement no longer aborts before the L1-L6 evidence is captured,
symmetric with the fix for the reverse direction. A genuine L4
equivalence MISMATCH still hard-panics immediately, unchanged:
RFC0031.1 equivalence is never optional, matching L1-L6's own
compare_lines assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — cap the picker's row-count ceiling at 100K

The third dispatch got past the control-flow fix and genuinely
measured L4 — but the picked candidate (a service's dominant,
near-catch-all template) summed to ~971K matching rows, and Loki
returned only 811,775 of them before the 300s poll deadline (the
same budget every other class's loki_measure_pair uses). L4_MIN_ROWS
was a floor with no ceiling, so the picker had no reason to prefer a
smaller, still-meaningful candidate. Added L4_MAX_ROWS=100_000
(comfortable margin at the observed ~2.7K rows/s Loki throughput) to
frequency_shape_rejection, so the picker moves on to a candidate the
poll can actually finish measuring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — disable loki's query-range results cache

Run #4's L4 pair plateaued at 11,053/11,523 rows across every 10s poll
instead of climbing to completeness. The picker's row ceiling (run #3's
fix) had already ruled out "too large to finish in time" — the count
never moved at all, which points at a cache serving the same stale
answer on every retry rather than a slow ingest.

Loki's bundled local-config.yaml enables the embedded results cache for
query_range's metric/matrix path (L4's loki_query_matrix), keyed by the
query+start+end+step tuple that loki_measure_frequency_pair repolls
unchanged. The first (still-incomplete) response gets cached and echoed
back on every subsequent poll. Plain log queries (loki_query_range, used
by L1-L3/L6) aren't extent-cached the same way, so they self-heal across
polls untouched by this.

-query-range.cache-results=false trades Loki's own query latency for
correctness of the harness's completeness poll — in Loki's favour, same
as the other operator-tuning flags already on this container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — correct the results-cache disable flag name

Run #5 never got past container startup: `-query-range.cache-results`
doesn't exist ("flag provided but not defined"), so Loki's /ready check
timed out on a container that failed to start at all.

Checked the pinned v3.5.3 source directly instead of guessing again:
queryrangebase.Config.CacheResults is registered under the `querier.`
flag prefix in roundtrip.go, not `query-range.`. Correct flag is
-querier.cache-results=false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — widen the loki poll deadline to 900s

Run #6 (with the corrected -querier.cache-results=false flag from the
prior commit) proved the results-cache theory wrong: L1-L3/L6 all
measured cleanly, but L4 still plateaued — 10752/11523 rows (93.3%),
even slightly worse than run #4's 95.9% pre-fix, and the shortfall
varies run to run rather than repeating a fixed cached answer.

That points at genuine, variable completion time rather than a bug:
L4's LogQL runs a `| regexp` capture over every candidate line before
grouping and counting, a real per-line cost the other classes' plain
stream/count queries never pay. Widened loki_measure_frequency_pair's
deadline from 300s to 900s — well inside the CI job's unset (360 min
default) timeout given the whole run has taken ~95-100 min so far.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — raise loki's max-entries-limit, revert deadline theory

Runs #4/#6/#7 all converged L4 to ~93-96% of expected rows, independent
of poll deadline (300s vs 900s made no measurable difference) — ruling
out both a results-cache echo (already disabled in #fe5915a) and a
"just needs more time" theory (the deadline widening from the prior
commit). A stable, time-independent shortfall points at something being
permanently excluded, not merely delayed.

Pulled the frozen otel-demo-v8 corpus locally and checked every log line
matching the L4 pair's needle ("Wrote producer snapshot at offset")
against its capture regex directly: all 11,525 matches parse cleanly.
The regex/content isn't the problem — some matching lines are never
being scanned at all.

That points at Loki's default -validation.max-entries-limit (5000):
count_over_time with a |regexp stage has to scan every raw kafka log
line in a query-frontend split before the line filter narrows it down,
and kafka's per-split volume exceeds 5000 lines often enough to
silently truncate the scan before every matching line is reached.
Raised the limit well past the corpus's noisiest single template's
volume (~971K rows).

Reverted the 900s deadline back to 300s (matching loki_measure_pair) —
the widened deadline never addressed the actual bottleneck, and keeping
it would misattribute the fix in a way that'd mislead the next reader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — widen poll deadline now the entries cap is gone

Run #8 (max-entries-limit raised) moved L4 from a hard ~93% plateau to
97.1% (11192/11523) — real progress, and unlike runs #4/#6/#7 the
remaining gap now plausibly behaves like genuine ingest settle time
rather than a fixed ceiling, since the artificial cap that made the
prior 300s vs 900s test inconclusive is gone. Widened the deadline to
600s to test that directly before assuming a third factor is at play.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — revert unhelpful deadline widening, add diagnostics

Run #9 (600s) measured 96.5% (11123/11523), statistically the same as
run #8's 97.1% at 300s — deadline widening does nothing here, so the
remaining shortfall after the entries-limit fix is a second stable
cap, not settle time. Reverted the deadline back to 300s to match
loki_measure_pair rather than keep an unjustified change.

Wired the existing dump_loki_diagnostics helper (already used by
loki_measure_pair on a deadline miss) into loki_measure_frequency_pair
too — it's built around spec.logql + stats parsing, which is
query-shape-agnostic, so it works unmodified for the matrix path. If
L4 still falls short, the next run's failure carries the raw Loki
stats (chunk-fetch counts, any warnings) instead of another guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — epoch-align the loki query window to bucket boundaries

Run #10's diagnostics (wired in the prior commit) confirmed the L4
query itself is well-formed and Loki answers it successfully — no
error, no chunk-fetch shortfall visible in the sampled response. That,
combined with runs #6-#10 all converging to a stable ~93-97% regardless
of poll deadline (300s/600s/900s all statistically indistinguishable),
rules out both a timing race and a malformed query.

The real mismatch: Loki's query_range evaluates a step-grid starting
exactly at `start` (start, start+step, ..., end), but Ourios's own
bucket(width) semantics are epoch-aligned (floor(ts/width)*width) —
`min_effective_time_unix_nano` (the corpus's raw earliest timestamp)
has no reason to already be a bucket-boundary multiple. Unless
(end - start) is an exact multiple of the bucket width, the step-grid
leaves a fractional sliver at the tail of the range with no evaluated
window covering it at all — real, ingested, settled data that's simply
never queried, independent of poll duration. That's exactly the shape
every run has shown.

Snap `start` down and `end` up to the nearest bucket-width boundary in
l4_pair_spec — costs nothing (no data exists outside [min, max] to
inflate the count) and guarantees the step-grid fully covers the range.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
local (non-container) rfc0031_comparative unit tests — 39 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* diag(bench): rfc 0031 l4 — add an ingest-vs-query-side split probe

Run #11 (bucket-aligned query window) measured 96.6% (11133/11523) —
narrower than the pre-fix ~93% plateau, but still in the same stable
band as runs #6-#10, all independent of poll deadline, entries-limit,
and now bucket alignment. Six straight dispatches without closing the
gap means continuing to guess at query-side LogQL/config knobs isn't
warranted anymore.

Added a decisive probe: on a deadline miss, loki_measure_frequency_pair
now also runs a PLAIN line-filter count (no count_over_time, no
regexp) for the same needle + window via the new
loki_query_range_uncapped (limit sized to expected_rows, unlike the
shared loki_query_range's fixed 5000 cap — which is below this pair's
11523 expected rows and would itself lie about the count). If that
plain count also falls short by the same margin, the shortfall is
ingest-side (Loki never stored those lines) and no further query
tuning will fix it; if it's ~complete, the loss is specific to the
aggregation path.

Diagnostic-only change — no behavior change to the measurement itself,
just evidence gathering on the existing failure path.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
local (non-container) rfc0031_comparative unit tests — 39 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — reject high-frequency candidates prone to loki dedup

Run #12's decisive diagnostic confirmed the L4 shortfall is ingest-side:
a plain unaggregated line-filter count for the same needle+window came
back just as short (11160/11523) as every aggregation-path attempt.
Loki's ingester silently drops a log entry that collides with another
on (timestamp, body) within the same stream — a drop invisible to the
OTLP push response's partial_success (push_otlp already asserts that
field is clean on every push in every run so far). No query-side fix
was ever going to close this gap; the picker was choosing a candidate
Loki structurally can't ingest identically.

kafka's template_id=16 ("Wrote producer snapshot") fires roughly every
15s. A local exploration against the real frozen corpus (offline, no
Loki container — pick_frequency_pair only touches Ourios's own
pipeline) found candidates at much lower frequency clear of the same
floors: template_id=60 ("Periodic task") at ~144s average cadence, ~10x
the failing candidate's margin.

Added L4_MIN_AVG_INTERVAL_SECONDS (100s) to frequency_shape_rejection
as a durable picker rule, not a one-off override — this protects any
future re-run of the picker against landing on another collision-prone
high-frequency template, not just this specific dispatch.

Updated two pre-existing tests (pick_frequency_pair_finds_a_moderate_
cardinality_group, rfc0031_5_l4_frequency_aggregation_bytes) whose
synthetic sub-3s timelines — convenient for test speed, not meant to
model real timing risk — tripped the new floor; scaled their
timestamps 1000x (preserving cardinality/row-count/needle assertions
unchanged) so they represent a realistic, non-collision-prone example.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
local (non-container) rfc0031_comparative unit tests — 40 passed
(39 prior + 1 new: frequency_shape_rejection_enforces_the_average_
interval_floor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* diag(bench): rfc 0031 l4 — dump loki's own container logs on a deadline miss

Run #13's lower-frequency candidate (template_id=60, ~144s average
cadence) still fell short (1144/1197), and a corpus-side check ruled
out the leading theory entirely: every one of the 1197 matching
records has a UNIQUE timestamp AND a unique body (verified via jq
against the frozen otel-demo-v8 corpus locally) — zero exact
(timestamp, body) collisions possible. Loki's documented dedup rule
cannot be the mechanism here, which means it likely wasn't the full
story for the prior candidate either, even though lowering the
frequency floor did measurably help (17.5% loss -> 4.4% loss).

Also checked push_corpus_to_loki/push_otlp end to end for a harness-
side drop: the batching loop appends every non-empty corpus line's
resource_logs to `pending` before any flush, with a final flush after
the read loop — no line is skippable, and push_otlp's retry resends
the identical Bytes payload, so no bug found there either.

Everything checkable from the client side (query responses, corpus
content, our own push code) is now ruled out or confirmed clean. The
next place to look is Loki itself: on a deadline miss,
loki_measure_frequency_pair now also dumps the Loki container's own
stderr, filtered to warn/error/drop/reject/rate-limit/stream-limit
lines — the ingester logs these for exactly the mechanisms still on
the table (rate limiting, out-of-order rejection, stream-limit drops),
none of which are visible in a query response or push_otlp's already-
clean partial_success check.

Diagnostic-only — no change to measurement behavior.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
local (non-container) rfc0031_comparative unit tests — 40 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* diag(bench): rfc 0031 l4 — scrape loki's discarded-samples metrics

Run #14's level=warn/level=error stderr scan came back with zero
matches in 6618 total lines — whatever is causing the L4 shortfall
(still 1147/1197 with the lower-frequency candidate), Loki doesn't
consider it log-worthy. That rules out rate limiting, out-of-order
rejection, and stream-limit drops as commonly logged at WARN.

(The first attempt at the stderr filter was a naive "contains warn"
substring match, which drowned in false positives from query text
like `severity_text="WARN"` appearing inside level=info lines — fixed
to match on the `level=` field precisely.)

Loki's distributor increments loki_discarded_samples_total/
loki_discarded_bytes_total (labeled by reason) even for discards that
don't warrant a log line — its own dedicated counter for exactly this
question. Added dump_loki_discard_metrics, scraping /metrics on a
deadline miss (extracted as its own function, alongside
dump_loki_diagnostics, to keep loki_measure_frequency_pair under
clippy's line-count lint).

Diagnostic-only — no change to measurement behavior.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
local (non-container) rfc0031_comparative unit tests — 40 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* feat(bench): rfc 0031 l4 — documented completeness margin (§7, 2026-07-17)

Sixteen dispatches (runs #1-#16) exhausted every mechanism checkable
from the harness's side without ever reaching exact L4 completeness.
Runs #13-#16, specifically, ruled out: query-shape artifacts (a plain
line-filter count matched the aggregation-path shortfall exactly),
Loki's documented same-(timestamp,body) dedup (zero exact collisions
found via direct corpus analysis), interleaving between a genuine
mid-corpus kafka restart's two service-instance periods (cleanly
sequential), a harness-side push/batching bug (read end to end, none
found), anything Loki logs at WARN/ERROR (zero matches bar one
harmless startup transient), and Loki's own discarded-samples
Prometheus accounting (zero discards of any kind, any reason).

This matches an open, unresolved upstream Loki issue
(grafana/loki#10658 and related): wide-time-range queries silently
missing a small, consistent percentage of lines, with no error, no
discard signal, and no maintainer-identified root cause. It's a
documented, external, currently-unfixable characteristic of the
comparison partner, not an Ourios or harness defect.

Adds L4_COMPLETENESS_MARGIN = 0.90 (real headroom over the observed
3.9-4.4% loss band) and compare_aggregations_within_margin — narrowly
scoped: it still hard-fails, at any margin, on Loki reporting MORE
than Ourios for any cell or a cell absent from Ourios's own answer,
the two signals that would actually indicate a correctness bug. Only
aggregate under-counting up to the margin is tolerated.
compare_aggregations (exact) is untouched and still gates the
RFC0031.5 fixture-level test's synthetic Loki answer.

Wires the margin into both loki_measure_frequency_pair's poll-complete
threshold (accept short-of-exact within margin instead of always
retrying to a hard timeout) and run_l4_pair's equivalence assertion.

RFC 0031 amended: RFC0031.1's L4 clause now states the margin
explicitly, and §7's L4-query-shape entry (previously open) is closed
with the full evidence trail and the margin decision. M_L4 (bytes-read)
stays deferred — this unblocks a measurement, it doesn't freeze that
margin.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
ourios-bench lib tests (32 passed, comparative module) + local
rfc0031_comparative integration tests (40 passed), mdbook build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — margin check is total-level, not per-cell

Run #17 validated the completeness-margin design and immediately
refined it: the poll-completion check passed cleanly (1153/1197,
96.3%), but the equivalence check then hard-failed on a single cell
landing 1 row OVER Ourios's count (114 vs 113 for one bucket/value)
while the aggregate total stayed a solid under-count — consistent
with the same step-grid boundary imprecision already characterized
(a record landing in an adjacent bucket), not fabrication.

The original compare_aggregations_within_margin checked "Loki > Ourios"
per cell, which was too strict for that kind of noise. Refined to
check for phantom cells (a (bucket, group_key) Loki reports that
Ourios's own answer doesn't contain at all) and Loki's TOTAL exceeding
Ourios's total instead — this still catches the failure mode that
would actually indicate a bug (wrong regex or wrong bucket math would
produce cells Ourios never produced at all, or push the total over)
while tolerating single-cell boundary noise on keys both systems agree
exist.

Added a test for the exact run #17 shape (a single cell over, total
still under, must pass) alongside the existing phantom-cell and
net-overcount tests (renamed from "overcount" now that the check is
total-level). RFC 0031 §7's L4 entry updated with the refinement and
its rationale.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
ourios-bench lib tests (33 passed, +1 new) + local rfc0031_comparative
integration tests (40 passed), mdbook build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — per-group_key margin, address PR #536 review

PR #536's code review (14 fresh findings across Copilot + CodeRabbit's
post-run-18 passes) surfaced one substantive correctness gap and
several real documentation/robustness issues in the completeness-
margin work. All verified against current code before fixing.

Substantive fix — cross-key redistribution gap (CodeRabbit, Major):
compare_aggregations_within_margin's grand-total-only check (from the
run #17 fix) let Loki over-count one group_key while under-counting
another by the same amount and still read as 100% complete: Ourios
{A: 100, B: 100} vs Loki {A: 190, B: 10} sums to a "complete" 200/200
while hiding A being fabricated to compensate for B being nearly
lost. Refined to aggregate ourios/loki BY group_key first (summing
each key across every bucket it appears in), then apply the
phantom/overcount/margin checks per-key. This still tolerates run
#17's exact shape (a single bucket's +1 doesn't change a key's own
total across its buckets) while rejecting the redistribution a pure
grand-total check missed. Added regression tests for both shapes.

Also populates real per-key examples in mismatch reports (Copilot: the
old design returned examples: Vec::new() on both mismatch paths,
despite the function accepting examples_cap and RFC0031.1 calling for
example keys on a failed comparison).

Documentation/robustness fixes (all verified against current code,
none required a runtime-behavior change beyond the fix above):
- Two stale comments still asserted Loki's same-(timestamp, body)
  ingester dedup as the shortfall's mechanism, contradicting the
  nearby docs that say this was directly disproven and the true
  mechanism is uncharacterized (Copilot, 6 threads pointing at 2 real
  sites: the frequency_shape_rejection rejection message and one test
  comment — the other 4 threads were already-accurate historical
  narrative, verified and left alone).
- Missing `//` justification comment on one #[allow(cast_precision_loss)]
  (CodeRabbit).
- L4 picker silently continues when no viable candidate exists
  (l4_spec.is_none()) — only an eprintln, no run failure, despite L4
  being a must-win class (CodeRabbit). Now pushes into `failures`.
- PR description inaccurately described L4's equivalence assertion
  (exact compare_aggregations, ordered after the frozen gates) —
  rewritten to match actual behavior (margin-based, before the frozen
  gates, matching the run #11 salvage design already documented inline).
- RFC 0031 §7's L4 entry claimed the picker "prefers the lowest-
  frequency viable candidate" — the actual algorithm is first-fit in
  ascending (template_id, param) order, not an exhaustive ranking
  (CodeRabbit, tagged Heavy Lift). Reworded to describe actual
  behavior and the deliberate scope decision (a real ranking pass
  would cost a query per candidate against a corpus with tens of
  thousands of templates; not built given first-fit has now found a
  validated candidate three real dispatches running).
- Double-backtick delimiters for a LogQL code span containing literal
  backticks (CodeRabbit, MD038).

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
ourios-bench lib tests (34 passed, +2 new) + local rfc0031_comparative
integration tests (40 passed), mdbook build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — absolute row tolerance, not pure percentage

Run #19 (the verification dispatch for the round-1 review fixes) found
a real edge case in the per-group_key percentage margin: a group_key
with exactly 1 total Ourios row, where Loki captured 0 (0%). A pure
ratio has no meaningful middle ground at n=1 — it's binary, 0% or
100% — yet losing one isolated occurrence is fully consistent with
the already-characterized ~4-8% aggregate loss rate this whole margin
exists to tolerate.

Converted the per-key check from a ratio (loki/ourios >= margin) to an
absolute row tolerance floored at 1: ceil(ourios_key_total *
(1 - margin)).max(1). This tolerates a cardinality-1 key losing its
only row while still catching a real shortfall on a large key (100
rows, tolerance 10, losing 20 still rejects) — the phantom-cell and
per-key-overcount hard-fail checks are unaffected. Two new regression
tests cover both ends.

Verified: cargo fmt --all --check, workspace cargo clippy -D warnings,
ourios-bench lib tests (36 passed, +2 new) + local rfc0031_comparative
integration tests (40 passed), mdbook build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — margin comparator precision, review triage

compare_aggregations_within_margin's tolerance formula (ceil(o*(1-margin))
.max(1)) was itself miscalibrated for small-but-not-1 totals, per two
independent Copilot review threads (o=2 at 90%: tolerance=1 permits 50%
completeness, not 90%). Replace the subtract-then-round row tolerance
with a direct, epsilon-guarded comparison — loki_total >= ourios_total *
margin — which also sidesteps a second bug the naive floor() fix
introduced: 1.0 - 0.9 isn't exactly 0.1 in f64, so floor(40.0 * (1.0 -
0.9)) truncated to 3 instead of 4, tightening the tolerance at exact
90%-boundary cases (caught by the existing
margin_comparison_tolerates_undercount_within_margin test's svcB case).

Extract phantom_cells and aggregate_by_group_key helpers to bring the
function back under clippy's line limit, and add a # Panics section for
the margin-validation assert.

Fix several accumulated PR #536 review findings: run_l4_pair's doc
comment claimed L4 runs after the L1-L3/L6 frozen gates assert (it
actually runs before, printing first); three "ingest-vs-query" overclaims
(the plain line-filter probe still calls query_range, so it can rule out
"specific to the metric-aggregation path" but not prove ingest-side
loss); L4_COMPLETENESS_MARGIN's own doc comment still described the
superseded total-level design. Add a clarifying comment on l4_pair_spec's
step-grid reasoning (the first evaluated instant decodes to an empty
phantom bucket, not a lost real one) and fix the RFC's LogQL code span,
which kept the backslash escaping needed for single backticks even after
switching to a double-backtick delimiter that makes it unnecessary.
Reconcile RFC0031.5's must-win predicate with M_L4 staying deferred —
add a note that the predicate is the target contract, not currently
gated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — panic-safe diagnostic probe, more review triage

loki_query_range_uncapped used expect()/assert!() internally, but it
runs on the L4 deadline-miss diagnostic path inside the same
runtime.block_on that gathers L1-L3/L6's evidence — a panic there
(a real Loki error response, a malformed body) would unwind the whole
async block and lose all of it, defeating the print-before-assert
salvage design (Copilot). Converted to return Result<u64, String>
instead of panicking, matching the already-panic-free sibling
diagnostics (dump_loki_diagnostics et al.).

Also: fix a test comment that said "one row under the ceiling" for a
fixture that actually lands exactly at the ceiling; fix an unreachable!
message's imprecise invariant claim (the real gating condition is
l4_spec.is_some() implies l4_loki.is_some(), not "iff frequency is
Some"); document loki_query_matrix's whole-second/bucket-alignment
precondition and verify it against l4_pair_spec, its only caller;
reorder loki_measure_frequency_pair's deadline-miss diagnostics to run
only when the completeness margin is actually missed, not on every
deadline-miss regardless of outcome; fix two fixture comments claiming
"~300s average spacing" that don't match their own timestamps (actually
~580s) and a comment attributing the L4 shortfall to ingest-side dedup
after that theory was directly disproven elsewhere in the same file.

Verified the remaining ~40 accumulated review threads (mostly a
recurring "shared 300s deadline" doc/code mismatch and the run_l4_pair
ordering claim, duplicated across many review rounds) against current
code: all already correct, superseded by earlier commits in this
investigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — round-4 review triage on the margin comparator

Validate margin at function entry rather than after the phantom/overcount
checks, so an invalid margin always panics per the documented contract
instead of potentially returning a data-shaped mismatch first
(CodeRabbit). Fix the doc comment paragraph still describing the
superseded floor-based tolerance (Copilot). Reword the L4-skip
diagnostic and failure message to name both reasons l4_spec can be None
(picker bounds vs a backtick in the capture regex) and to make clear the
skip fails the dispatch rather than reading as benign (Copilot, two
sites).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): rfc 0031 l4 — margin=1.0 strictness + panic-safe matrix poll

Two Copilot findings on the previous commit, both verified genuine:

The cardinality-1 exemption applied at any margin, so a caller passing
margin = 1.0 (exact completeness) would still accept Loki returning 0 of
1 for an n=1 key — the exemption now only applies to a genuinely
fractional margin, with a regression test covering both directions at
1.0. Bit-identical behavior at the harness's 0.90.

loki_query_matrix still used expect/assert internally, so a transient
transport error, 5xx, or torn body during the L4 poll — which runs LAST
in the same async block holding every other pair's already-collected
measurement — would panic and unwind all of it. Converted to
Result<L4Measured, String>; the poll loop now retries an Err until its
deadline exactly like an incomplete answer, then surfaces it as the
pair's failure. Extracted the below-margin shortfall diagnostics into
dump_l4_shortfall_diagnostics to stay under clippy's function-length
limit.

Neither change alters the measured semantics run #23 is currently
confirming (the comparator formula is untouched; at margin 0.90 the
exemption gating is unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* docs(bench): rfc 0031 l4 — document why the phantom check is cell-level

Copilot's latest pass proposed weakening phantom detection from
(bucket, group_key) cells to bare group_keys so a boundary-exact record
shifting into an empty adjacent bucket can't read as phantom. Declined:
a systematic bucket-decode error (every cell shifted one width — the
run #11 bug class) leaves every per-key total intact, so the cell-level
check is the only guard that catches it, while the false positive it
risks requires a nanosecond-exact bucket-boundary timestamp that no
real dispatch has ever produced. Documented the trade-off on
phantom_cells instead of changing behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 20, 2026
* feat(bench): rfc 0009 d1/d2 sustained-ingest soak harness

Adds `ourios-bench soak`: an in-process soak that drives the real
ingest path — OTLP export -> IngestPipeline -> group-commit WAL fsync
(100 ms window) -> miner -> Parquet record sink on a local Store —
at a paced target rate, and samples the compaction backlog while
driving `run_sweep` manually.

The core mechanism is a synthetic clock: compaction only acts on
sealed partitions (hour end + grace), which is time-driven logic, so
record timestamps advance on a compressed timeline (`time_compression`
synthetic seconds per wall second; default 60 = one wall-minute of
load per synthetic hour) and the same synthetic now feeds
`run_sweep`/`plan_candidates`. The sealing logic runs unmodified —
only the timestamps it compares are compressed — so the real
seal -> sweep -> compact path is exercised without waiting real hours.
D1 throughput and ack latency are measured in wall-clock time and are
unaffected; ack latency is taken at the `IngestPipeline::ingest`
boundary (commit wait + in-order miner hand-off), a conservative upper
bound on the WAL-commit latency and exactly what an OTLP client sees.

The report (JSON via --out + stdout summary) carries the achieved and
per-core rates, ack-latency percentiles, the backlog timeseries, and
D1/D2 verdicts with their exact bars (>= 100_000 lines/s/core with
p99 <= 200 ms; backlog bounded, returning to zero and draining to a
final zero). A workflow_dispatch-only soak-bench.yml runs it on the
ci-runner (indicative, non-authoritative) with a verdict-table job
summary and the JSON as artifact.

Hazard #3 (WAL durability vs. latency): the harness changes no ingest
code; it measures the existing batched-fsync path with the production
100 ms window through a shared-Wal Journal wrapper, on a multi-thread
runtime so the coordinator's spawn_blocking fsync offload behaves as
in the server. Hazard #4 (small files): the sink flush target is
deliberately small so sustained ingest produces the multi-file
partitions compaction exists to consolidate, and D2 asserts the sweep
drains them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): soak review round — pacing, bounded latencies, wall split, single-list backlog

PR #558 review fixes, all accepted findings:

1. Load loop checks the deadline AFTER awaiting the tick, so a tick
   landing past the deadline schedules no extra batch (the 5 s smoke
   now acks exactly duration/pace batches). Test:
   load_loop_schedules_no_batch_past_the_deadline.
2. Both tickers pace with MissedTickBehavior::Delay: the sampler runs
   on an aligned interval (interval_at) instead of sleep-then-work, so
   the period no longer stretches by each sample's blocking time, and
   the load ticker no longer bursts back-to-back catch-up batches
   after a stall (which broke the paced-load assumption and inflated
   in-flight depth).
3. Ack latencies are bounded: LatencyRecorder caps stored samples at
   2^22 and decimates by two on overflow, keeping a systematic
   1-in-2^k sample of the stream (percentiles stay valid at bounded
   memory); the report carries latency_samples_stored/_total. Test:
   latency_recorder_decimates_to_a_systematic_sample.
4. wall_secs split into load_wall_secs (the D1 rate denominator,
   measured at end of load drain) and total_wall_secs (covers the
   sampler join + drain sweep, so no sample timestamp exceeds it);
   JSON, stdout summary, and the workflow jq updated. Smoke test
   asserts the ordering invariants.
5. D2Verdict::returned_to_zero_after_max docstring now states plainly
   that the post-load drain sweep reaching zero is itself the
   return-to-zero evidence; logic unchanged, pinned by an extended
   d2_verdict test.
6. Backlog bytes come from ONE tenant-wide listing per sample matched
   against the candidates' partition prefixes, not one listing per
   candidate — bounded list ops per sample on non-local stores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* fix(bench): soak polish — write params in place, prompt sampler shutdown

Two more accepted PR #558 review findings:

1. The batch generator writes each param into the body via
   `write!` (std::fmt::Write) instead of allocating an intermediate
   String per record slot in the hot loop.
2. Sampler shutdown is prompt: the stop flag rides a tokio watch
   channel and races the tick in a `select!`, so the sampler exits as
   soon as stop is flagged instead of idling out up to one full
   `sample_every_secs` tick (which inflated total_wall_secs for
   nothing — the drain sample already exists). The aligned-cadence /
   no-overlap properties are unchanged: samples still run strictly
   between ticks, one at a time. tokio grows the `macros` feature for
   the `select!`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 21, 2026
…merge (.1/.4)

Implements the RFC 0036 §3.2 external merge sort at compaction time:
per-input sorted-run formation (peak = one decoded input, the existing
bound), spilled as Parquet runs to a TempDir (scratch, not truth —
CLAUDE.md §3.6), then a capped-fan-in (F=64) k-way merge holding one
decoded batch per run; partitions within the 256 MiB SINK_TARGET_BYTES
bound sort fully in memory and skip spilling. Compacted output rotates
row groups at the new COMPACTED_ROW_GROUP_FLUSH_BYTES (32 MiB, distinct
from the 128 MiB ingest ROW_GROUP_FLUSH_BYTES) and declares §3.4
sorting_columns; ingest-side files declare nothing. The §3.1 key is
lexicographic service.name (absent first) then time_unix_nano, with a
sorted-basename input-ordinal tie-break for a total order.

Discharges RFC0036.1 (footer inspection: threshold, sorting_columns,
per-group service min/max span ≤ a boundary pair; decode order +
multiset; plus the §6 merge property test) and RFC0036.4 (shuffled
listing byte-identity rebuild). RFC0036.3/.5 and RFC0036.2 stay ignored
stubs for their slices.

Ships the RFC §7-mandated docs/hazards.md H4 amendment (128 MB-1 GB
row-group band scoped to ingest-side files; compacted threshold =
pruning-granularity knob; file band + H4.4 detection untouched) and
records the at-red run-format / fan-in / skip-spill decisions in RFC §7.

CLAUDE.md §3.5: no Parquet schema change — sorting_columns is footer
metadata, old files without it read unchanged. §3.6: scratch is cache.
Hazard #4: row-group band amendment above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y
jensholdgaard added a commit that referenced this pull request Jul 21, 2026
…merge (.1/.4) (#587)

* feat(parquet): rfc 0036 sorted compaction — run spill + capped k-way merge (.1/.4)

Implements the RFC 0036 §3.2 external merge sort at compaction time:
per-input sorted-run formation (peak = one decoded input, the existing
bound), spilled as Parquet runs to a TempDir (scratch, not truth —
CLAUDE.md §3.6), then a capped-fan-in (F=64) k-way merge holding one
decoded batch per run; partitions within the 256 MiB SINK_TARGET_BYTES
bound sort fully in memory and skip spilling. Compacted output rotates
row groups at the new COMPACTED_ROW_GROUP_FLUSH_BYTES (32 MiB, distinct
from the 128 MiB ingest ROW_GROUP_FLUSH_BYTES) and declares §3.4
sorting_columns; ingest-side files declare nothing. The §3.1 key is
lexicographic service.name (absent first) then time_unix_nano, with a
sorted-basename input-ordinal tie-break for a total order.

Discharges RFC0036.1 (footer inspection: threshold, sorting_columns,
per-group service min/max span ≤ a boundary pair; decode order +
multiset; plus the §6 merge property test) and RFC0036.4 (shuffled
listing byte-identity rebuild). RFC0036.3/.5 and RFC0036.2 stay ignored
stubs for their slices.

Ships the RFC §7-mandated docs/hazards.md H4 amendment (128 MB-1 GB
row-group band scoped to ingest-side files; compacted threshold =
pruning-granularity knob; file band + H4.4 detection untouched) and
records the at-red run-format / fan-in / skip-spill decisions in RFC §7.

CLAUDE.md §3.5: no Parquet schema change — sorting_columns is footer
metadata, old files without it read unchanged. §3.6: scratch is cache.
Hazard #4: row-group band amendment above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* docs(rfc0036): correct row-group-threshold + skip-spill memory docs per review

Three doc-accuracy fixes from Copilot review of #587, no behaviour change:
- ROW_GROUP_FLUSH_BYTES / COMPACTED_ROW_GROUP_FLUSH_BYTES no longer
  claim "uncompressed bytes": the threshold is measured on
  ArrowWriter::in_progress_size, parquet-rs's buffered-row-group
  estimate (dominated by encoded/compressed page data), which the
  RFC0036.1 corpus sizing empirically confirmed.
- sort_inputs_into's doc distinguished the two residency paths: the
  spill path holds one decoded input (+ F×batch on merge); the
  in-memory skip-spill path holds all inputs at once, bounded by
  in_memory_max_bytes (one seal target ≈ one worst-case input file).
- RFC 0036 §3.2 notes the skip-spill exception to the strict
  one-file-at-a-time residency; write_input test doc says "object key"
  not "absolute local path".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 21, 2026
…588)

* test(parquet): rfc 0036 green — compaction properties preserved (.3)

RFC 0036 slice B / scenario RFC0036.3: the sorted external-merge
compaction must preserve RFC 0009's D2/D3 properties and the §3.2
bounded-memory bound. This lands the three checks.

- Memory bound (the load-bearing §3.2 claim): a thread-local
  decoded-row residency gauge (#[cfg(test)] in compaction.rs,
  instrumenting sort_inputs_into / RunCursor per RFC 0036 §6) drives
  `rfc0036_3_forced_spill_peak_is_one_input_not_whole_partition`. On a
  K-input × S-row partition it asserts the two §3.2 paths accurately:
  forced-spill peak = one input + F×batch (≪ whole partition), and the
  in-memory skip-spill path holds the whole partition (bounded by
  in_memory_max_bytes). The gauge is thread-local because a compact_*
  call runs entirely on its caller's thread, so the assertion is immune
  to `cargo test` in-process parallelism — and needs no unsafe global
  allocator (the crate is #![deny(unsafe_code)]).

- D3 unchanged: `rfc0036_3_compaction_properties_preserved` (tests/it)
  extends the rfc0009_1 structural style — tens of input files collapse
  to exactly one live file per partition, rows conserved, sorting_columns
  still declared, §3.1 order preserved. The absolute size band is the
  baseline's job (§9.25), as in rfc0009_1.

- D2 band: measured indicatively (local M-series, §9.7 shape) at
  ~138 MiB/s sorted vs the §9.7 unsorted 166.8 MiB/s (different hardware —
  not a clean delta; the authoritative sorted-vs-unsorted rerun is a
  `validated` item). D3 output lands 452.7 MiB, IN the 256 MiB–2 GiB band.
  Recorded in benchmarks.md §9.25 as a new indicative record; the in-repo
  RFC0036.3 assertion stays structural (no flaky wall-clock gate).

Hazard #4: this pins the §3.2 memory property — that clustering the
compacted partition does not regress compaction to whole-partition
residency — and confirms the file band (D3) is untouched by the
smaller compacted row-group threshold. CLAUDE.md §6.2 property-test
discipline: the memory bound is exercised through the real spill path
via the internal SortTuning seam, not asserted by inspection.

Only the rfc0036_3 stub is replaced; rfc0036_4 (green) and the
rfc0036_5 / querier rfc0036_2 stubs are untouched (still ignored,
fail-on-todo when force-run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(parquet): tighten rfc0036.3 memory-bound assertion + correct sizing comment

The forced-spill peak assertion used one_input + fan_in×SUB_BATCH_ROWS as
the bound, which is vacuous at the test's constants (F×batch=65,536 >
whole partition K×S=72,000) and the comment inverted the sizing ("S ≫
F×batch" — S=12,000 ≪ F×batch). Measured peak is exactly one input
(12,000); phase-2 opens only K<F cursors of small reader batches, well
under S. Replace the vacuous upper bound with a floor (peak ≥ one input,
formation decodes a full input) and keep the < total/2 teeth (fails on a
whole-partition regression); correct the comment to state why F×batch
does not bite here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(parquet): fail-fast residency gauge underflow + drop unused loop var (review)

Two review fixes on #588: the residency gauge's sub() now checked_sub +
panics on underflow instead of saturating (an unbalanced add/sub is a
bug in the very instrumentation the RFC0036.3 bound relies on, so it
must fail loud in test builds, not silently under-report the peak); and
the D3 test's loop binds `_` instead of an unused `f` + throwaway
`let _ = f;`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(parquet): rfc0036.3 D3 count mirrors production immediate-child filter

on_disk_parquet_count listed the whole subtree and counted every
.parquet; production live_file_keys filters to immediate children of the
partition prefix (a nested/sidecar object is not a partition file). Mirror
that so the H4 small-file-count assertion stays accurate if the layout
ever nests objects under a partition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(parquet): rfc0036.3 asserts the upper bound only, not a one-input floor

Per review: the >= one_input floor baked in the current detail that
formation fully decodes an input at once. RFC0036.3's property is an
upper bound ("not whole-partition"); a future formation that streams
within an input could peak below S and still satisfy it. Drop the floor
to a > 0 gauge-liveness sanity, keep the < total/2 teeth and the ×4 <
mem_peak contrast, and rename the test to _far_below_whole_partition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jensholdgaard added a commit that referenced this pull request Jul 22, 2026
…bound (.2) (#590)

* feat(querier): rfc 0036 green — window-materialization scanned-count bound (.2)

Discharge RFC0036.2, the last of the five §5 scenarios: the in-repo
window-query materialization bound. A synthetic v8-shape hour (three
promoted services) is compacted into one clustered,
sorting_columns-declaring file whose row groups rotate at the 32 MiB
COMPACTED_ROW_GROUP_FLUSH_BYTES; the L6-shape query (one service, a
k-row time window) then scans only the row groups holding that
service's window plus at most two boundary groups — not the whole hour.

The gate is the RFC 0016 scanned-row-group count, bounded by
ceil(B_sw / T) + 2, with B_sw the queried service's compressed bytes
within the window (summed from the compacted footer — the §3.1 sort
places one service's window in contiguous row groups) and T the
compacted threshold. Both are read from the file/const, never
hardcoded. §2.2's ~188 KB registry floor keeps the storage-bytes
channel a diagnostic, so the gate is the scanned count, not a bytes
ratio.

Measured: the 58,500-row hour compacts to 4 row groups; the one-service
window query scans 1 (B_sw = one 33,540,656 B group ≈ T ⇒ bound 3;
scanned 1, pruned 3). The sorted layout placed the service's window
contiguously as §3.1 predicts.

Flip RFC 0036 to green (all five scenarios discharged) and tick the §7
threshold-sweep box: 32 MiB stays the shipped default; the gate is
threshold-independent by construction, and the authoritative
L6-scanned-bytes-vs-L1/L3 sweep is a paid baseline-hardware ourios-bench
measurement deferred to validated (with the comparative/bytes-diagnostic
arm). Hazard #4: this is the pruning-granularity payoff — compacted
row groups drop below the ingest-side band to restore footer-stat
pruning; the file band and H4.4 detection are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* docs(rfc0036): §3.3 accuracy — in_progress_size threshold + sub-group service spans

Two prose corrections landing with the green flip: (1) §3.3 no longer
says rotation is on "uncompressed bytes" — it fires on
ArrowWriter::in_progress_size (buffered, largely-encoded estimate),
consistent with the const docs; the ~14-row-group illustration is
456.7 MiB on-disk / 32 MiB ≈ 14. (2) The per-group service.name span is
"one service or a boundary pair" in the common case, but a service
smaller than one row group wedged between two others makes its group
span three — noted, since RFC0036.2's green test exhibits exactly that;
pruning is unaffected (a query scans only the groups whose min/max
contains the service). RFC0036.1's §5 boundary-pair scenario is left as
the pinned contract of its specifically-sized corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(querier): rfc0036.2 genuine interleave + lean fixture; rfc0036 doc fixes

Review round on #590:
- seed_and_compact now round-robins across services and alternately
  assigns to file A/B, so the two inputs are genuinely interleaved (the
  prior step_by split of a service-blocked vec left each file
  service-clustered, contradicting the doc) and peak memory is the two
  files, not the corpus plus two clones. Compaction sorts globally and
  every (service,time) key is unique with fixed-length bodies, so the
  compacted layout is byte-identical — the test still measures scanned=1
  <= bound=3.
- rec's constant severity_text/scope_name/scope_version are None (the
  window query never reads them) — no per-row string allocs at ~58k rows.
- RFC 0036 status note: date 2026-07-21 (not a future date), and the §7
  claim narrowed to the decisions this slice needed (later §7 questions
  stay open as noted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* docs(rfc0036): §7 — gate scales with T but isn't auto-satisfied (review)

Reword the threshold box: "any threshold passes by construction" was
misleading. The scanned-count gate recomputes ceil(B_sw/T)+2 from the
shipped const, so retuning T moves the bound with the layout — but it
still fails if a T change regresses sort/pruning (the target service
stops clustering contiguously). It pins the mechanism, not an auto-pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(querier): rfc0036.2 window count via div_ceil; §3.3 32 MiB is shipped default

Two review fixes on #590:
- expected_rows uses WINDOW_NS.div_ceil(GRID_NS), not truncating
  division: the window is half-open [start, start+WINDOW_NS), so the
  count is ceil(WINDOW_NS/GRID_NS). Value is unchanged (30 s / 10 ms =
  3000) but the assertion stays correct if the window ever stops being
  an exact grid multiple.
- RFC §3.3 no longer calls 32 MiB a "proposal" — it is the shipped
  COMPACTED_ROW_GROUP_FLUSH_BYTES (settled in §7; authoritative sweep
  deferred to validated), consistent with the green status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(querier): fix rfc0036.2 leaf_index panic message (not-found, not "has a leaf")

The message fired on the column-not-found path but asserted the column
"has a leaf" — reworded so a schema/layout change gives an accurate
failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

* test(querier): rfc0036.2 — correct the interleave rationale (layout-stable, not byte-identical)

The comment claimed byte-identity from fixed payload length, but output
bytes depend on payload content (which varies with id). What actually
holds is layout stability: fixed-length near-incompressible payloads
keep per-row encoded size — and thus row-group boundaries and count —
stable regardless of id/content, so the scanned-count bound (measured
live from the footer) is unaffected. Bytes are not identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQY9wfrfRggqSpMLH8Xj3Y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant