Skip to content

Make interview engine tuning numbers runtime-configurable - #5

Merged
Dustymon111 merged 2 commits into
mainfrom
feat/runtime-config
Aug 4, 2026
Merged

Make interview engine tuning numbers runtime-configurable#5
Dustymon111 merged 2 commits into
mainfrom
feat/runtime-config

Conversation

@Dustymon111

Copy link
Copy Markdown
Collaborator

Problem

Every tuning number in the interview engine was hardcoded. Changing something as basic as "minimum 2 probes before a skill can advance" meant a code change and a deploy — and some numbers were duplicated across the state engine, the analyzer prompt and the system prompt, so they could silently drift apart.

Approach

A single registry (app/lib/app_config.rb) declares every tunable with a type, range and code default, backed by a new app_settings table.

tenant override (app_settings.tenant_id = <tenant>)
  → global override (app_settings.tenant_id IS NULL)
    → legacy ENV var (only where a setting declares one)
      → code default
  • Reads are cached in Redis for 30s and the cache is invalidated on write, so a change reaches every Puma and Sidekiq pod within seconds — no deploy, no restart, no configmap edit.
  • If the DB or cache is unavailable, lookups fall back to the code default. Config can never take an interview down.
  • All defaults match the previously hardcoded values, so behaviour is unchanged until something is overridden.

How to change a value

Per tenant, via API (admin role) — writes are always scoped to the caller's tenant, so one tenant's admin can never change another tenant's behaviour:

GET    /api/v1/settings                      # every setting + effective value + source
PATCH  /api/v1/settings/:key   { "value": 3 }
PUT    /api/v1/settings        { "settings": { "coverage.min_probes_to_advance": 3 } }
DELETE /api/v1/settings/:key                 # drop this tenant's override

Globally, via rake (ops only — deliberately not exposed over the API):

rake app_config:list
rake app_config:set[coverage.min_probes_to_advance,3]        # global
rake app_config:set[coverage.min_probes_to_advance,3,12]     # tenant 12
rake app_config:reset[coverage.min_probes_to_advance]

What became configurable (29 settings)

coverage — probing depth

Key Default Was
coverage.min_probes_to_advance 2 hardcoded in StateEngine, the analyzer prompt and the system prompt
coverage.auto_advance_probe_count 4 probe_count >= 4 in CoverageAnalyzerWorker
coverage.deep_probe_ceiling 4 "only continue if pacing=ahead" rule in the system prompt
coverage.max_discovered_skills 10 discovered.count >= 10
coverage.discovery_max_exchanges 3 "2-3 exchanges maximum" in the system prompt
coverage.analyzer_turns_context 6 Analyzer::TURNS_CONTEXT

pacing — minutes remaining per uncovered skill

Key Default
pacing.ahead_min_minutes 5.0
pacing.on_track_min_minutes 3.0
pacing.behind_min_minutes 1.5

portfolio — confidence rating

Key Default
portfolio.confidence_high_probe_count 3
portfolio.confidence_medium_probe_count 2
portfolio.max_evidence_quotes 3

session — timing and lifecycle

Key Default
session.time_warning_seconds 120
session.wrap_up_warning_seconds 60
session.time_ceiling_grace_seconds 60
session.wrap_up_silence_fallback_seconds 20
session.closing_phrase_fallback_seconds 15
session.end_poll_max_attempts 30
session.browser_grace_period_seconds 120

gemini — connection resilience

Key Default
gemini.proactive_reconnect_after_seconds 510 (still honours the PROACTIVE_RECONNECT_AFTER env var as fallback)
gemini.proactive_reconnect_jitter_seconds 30
gemini.max_reconnect_attempts 3
gemini.reconnect_backoff_seconds [1, 2, 4]
gemini.inactivity_timeout_seconds 30
gemini.gate_open_delay_seconds 0.8
gemini.silence_pump_delay_seconds 1.0

preflight — candidate device check (served to the frontend)

Key Default
preflight.min_upload_kbps 500
preflight.min_download_kbps 500
preflight.speed_test_payload_bytes 262144
preflight.speed_test_timeout_ms 10000
preflight.block_below_minimum true

Deliberately NOT configurable

SILENCE_PUMP_INTERVAL / SILENCE_FRAME_SAMPLES (16kHz PCM wire-format constants — a wrong value corrupts audio) and the +1-per-run probe increment cap (a correctness invariant, not a tuning knob).

When a change takes effect

Group Applies to
coverage.*, pacing.* next analyzer run / coverage injection (seconds)
portfolio.* next portfolio generation
session.*, gemini.* next WebSocket connection (read once per connection)
preflight.* next candidate device check

The probe numbers are also baked into the compiled system prompt stored on each assessment. Assessments now carry system_prompt_config_digest; when it no longer matches the current config the prompt is recompiled automatically as the next session connects, so a settings change reaches existing assessments too.

Frontend follow-up (not in this PR)

The minimum upload speed lives in the frontend repo today — the backend /api/v1/speed_test endpoint only ever echoed the byte count and made no pass/fail judgement. This PR adds the server side of it:

GET  /api/v1/preflight/config      → { "preflight": { "preflight.min_upload_kbps": 500, ... } }
POST /api/v1/preflight/speed_test  → { "received_bytes": 262144, "preflight": { ... } }

Both are unauthenticated (a candidate only has an invite link). POST /api/v1/speed_test is left untouched so the current frontend keeps working. The frontend still needs a change to read its thresholds from /api/v1/preflight/config instead of hardcoding them.

Migrations

  • 20260804000000_create_app_settings — new table, partial unique indexes on (tenant_id, key) and on key where tenant_id IS NULL (Postgres treats NULLs as distinct, so the global rows need their own index)
  • 20260804001 add_system_prompt_config_digest_to_assessments — one nullable varchar(64) column

Both are additive and safe to run on a live database.

Testing

AppConfig was exercised directly against a live Rails console: set/get/reset across all four types (integer, float, boolean, integer array), range rejection (must be <= 10), type rejection (must be a whole number), unknown-key rejection, and correct source reporting (tenant / global / env / default). The gate itself was verified end-to-end: with min_probes_to_advance = 3, StateEngine.resolve_state(initiated → covered, probe_count: 2) stops at initiated, and passes to covered at probe_count: 3.

⚠️ The migrations have not been run against a correctly-migrated database. The local dev DB is out of sync with db/migrate (5 migrations show as NO FILE, and the assessments table is absent), so create_app_settings ran locally but the digest column could not. db/schema.rb was edited by hand to reflect exactly the two migrations rather than a dump of that broken DB. Please run both on staging first.

🤖 Generated with Claude Code

Every threshold in the coverage, pacing, portfolio, session, Gemini and
preflight paths was hardcoded, so changing e.g. the minimum probe count
per skill required a code change and a deploy.

Introduce AppConfig: a registry of 29 settings with code defaults, types
and ranges, backed by an app_settings table. Values resolve tenant
override -> global override -> legacy ENV -> code default, cached in
Redis for 30s and invalidated on write, so a change reaches every Puma
and Sidekiq process within seconds without a restart. Lookups fall back
to the code default if the DB or cache is unavailable.

Writes are exposed per tenant via /api/v1/settings (admin) and globally
via rake app_config tasks — the API never writes the global scope, so one
tenant's admin cannot change another tenant's behaviour.

Probe minimums are also embedded in the compiled system prompt, so
assessments now store a digest of the config they were compiled under and
recompile lazily when it changes.

Candidate preflight thresholds (minimum upload/download speed) are served
at /api/v1/preflight/config for the frontend device check; the existing
/api/v1/speed_test route is unchanged.

Defaults match the previous hardcoded values, so behaviour is unchanged
until a setting is overridden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frontend blocks a candidate below 4 Mbps upload / 8 Mbps download.
Live audio is raw PCM over the WebSocket: the uplink is 16kHz x 16-bit
mono (256 kbps, ~341 kbps once base64-encoded) and the downlink is 24kHz
(384 kbps, ~512 kbps encoded). The old bar was more than 10x the real
requirement and rejected connections that would run the interview fine.

Set the defaults to those figures with ~2x headroom (750 kbps up,
1000 kbps down) and add preflight.max_ping_ms so the frontend can stop
hardcoding that too.

Add GET /api/v1/preflight/download_test, which streams incompressible
bytes so the client measures download throughput against this API — the
path the interview actually uses — rather than against a public CDN.

Throttle the unauthenticated preflight endpoints, and make Rack::Attack
survive a Redis outage: short timeouts plus an error handler so an
unreachable Redis degrades to "no rate limiting" instead of hanging the
candidate's first request. Give the production cache store the same
timeouts — AppConfig falls back to code defaults on error, but only if
the cache call returns at all.

Also load each override scope once in all/describe/public_values
instead of twice per key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Dustymon111
Dustymon111 merged commit bf599e4 into main Aug 4, 2026
1 of 2 checks passed
@Dustymon111
Dustymon111 deleted the feat/runtime-config branch August 4, 2026 13:53
Dustymon111 added a commit that referenced this pull request Aug 4, 2026
Stacked on #5 (base: `feat/runtime-config`) — merge that first.

## Problem

A 5-skill interview was finishing in ~10 minutes: roughly 2 minutes per
skill. Three causes compound.

### 1. A skill could jump `initiated` → `covered` in a single turn

`resolve_state` walked forward as many states as Flash proposed,
applying the *same* `probe_count >= 2` check at every hop:

```ruby
(current_idx + 1).upto(proposed_idx) do |i|
  break unless valid_transition?(from: result, to: candidate, probe_count: probe_count)
  result = candidate
end
```

So when Flash proposed `covered` for a skill sitting at `initiated`, the
loop passed `initiated→partial` (probe 2 ≥ 2 ✓) and then immediately
`partial→covered` (probe 2 ≥ 2 ✓). **Two candidate turns covered a
skill.** The analyzer runs once per candidate turn and caps
`probe_count` at +1 per run, so 5 skills × 2 turns ≈ 10 minutes —
exactly what was observed.

The second-order effect is worse than the arithmetic: no skill ever
*persisted* in `partial`, so the coverage map never once told the
interviewer *"partial — you've probed but signal is still thin. Go
deeper. Do NOT wrap up."* Every `partial` instruction in the system
prompt was dead code in practice.

### 2. `covered` reused the gate meant for leaving `initiated`

`coverage.min_probes_to_advance` (2) was the only depth requirement
anywhere. Nothing expressed "this skill has been probed enough to be
*done*".

Corroborating symptom: the portfolio grades `high` confidence at
`probe_count >= 3`, so with every skill finishing at 2 probes, **no
skill could ever be rated high confidence.**

### 3. Coverage completion ended the session regardless of the clock

`all_covered?` triggered wrap-up as soon as the agenda was complete. A
generous analyzer could therefore close a 45-minute interview at minute
15 and leave 30 minutes unused.

## Fix

**1. Cap advancement at one state per analyzer run** — new
`coverage.max_state_advance_per_turn` (default 1). A skill must sit at
`partial` for at least one turn, which is the only point at which the
interviewer is told to go deeper on it. Walking forward one step (rather
than rejecting a multi-step proposal outright) still prevents the "stuck
state" case the original loop was written for; it just costs one extra
turn.

**2. Separate gate for `covered`** — new `coverage.min_probes_to_cover`
(default 4), clamped to never fall below `min_probes_to_advance`
whatever the config says.
`CoverageAnalyzerWorker#advance_stale_partials` writes `covered`
directly without going through `StateEngine`, so it now applies the same
floor.

**3. Hold the wrap-up while time remains** — new
`session.min_elapsed_ratio_before_wrap_up` (default 0.6). While held,
the coverage map carries `wrap_up_blocked: true` and `deepen_next:
<thinnest skill id>`, and the system prompt instructs the interviewer to
push that skill harder rather than close — looking for the ceiling of
the candidate's ability, not a recap. Set to `0` to restore the old
behaviour.

> One trap worth flagging for review: once every skill is `covered` the
analyzer **freezes** them (`next if map.state == 'covered'`), so
`probe_count` stops incrementing and nothing in the coverage fingerprint
can ever change again. `refresh_coverage_cache` returns early on an
unchanged fingerprint, so without care the session would never
re-evaluate and never notice the hold expiring. The hold flag is
therefore part of `coverage_fingerprint`.

**4. Tighter confidence test in the analyzer prompt** — evidence must
span more than one situation (not the same story retold) and the
candidate must have been pushed at least once beyond their first answer.
Unsure → `partial`.

**5. System prompt** — states the new depth requirement with concrete
follow-up shapes, explains `wrap_up_blocked` / `deepen_next`, and adds
the one exception to "never ask about a covered skill".

## Verification

Turn-by-turn simulation through `StateEngine` (defaults: advance 2,
cover 4, max advance 1):

| Turn | State | Flash proposes | probe | Before | **After** |
| --- | --- | --- | --- | --- | --- |
| 1 | `not_yet` | `initiated` | 1 | `initiated` | `initiated` |
| 2 | `initiated` | `covered` | 2 | **`covered`** ← the bug |
**`partial`** |
| 3 | `partial` | `covered` | 3 | — | `partial` |
| 4 | `partial` | `covered` | 4 | — | `covered` |
| — | `not_yet` | `covered` | 9 | `covered` | `initiated` |

Wrap-up hold, 45-minute limit, all skills covered: 10m → held, 20m →
held, 27m → releases. Payload while held carries `wrap_up_blocked: true,
deepen_next: "s1"` (the skill with the lowest probe count). Fingerprint
verified to change when the hold expires. Tenant override
`min_probes_to_cover = 6` verified: probe 4 and 5 stay `partial`,
`covered` at 6.

## Impact on interview length

Roughly `skills × min_probes_to_cover` candidate turns at ~1 minute each
— 5 skills ≈ 20+ minutes at the defaults, against ~10 before.

⚠️ **Check `time_limit_min` on short assessments.** A 10-minute
assessment with 5 skills cannot fit 20 turns, so no skill reaches
`covered` and the session ends on the time ceiling instead. That is safe
— no hang, transcript and portfolio still generate — but every skill
will be rated low/medium confidence. For short assessments lower
`coverage.min_probes_to_cover` for that tenant, or configure fewer
skills.

All three thresholds are `AppConfig` settings, so the depth can be tuned
per tenant at runtime without a deploy — 3 vs 4 vs 5 can be compared on
staging directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <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