Skip to content

Self-Evolving Security

What counts as an unacceptable request is a property of your company, not of a model — so a detector that never learns from you is permanently approximate. A user or reviewer flags a bad decision, a curator approves it, and the correction lands on the timescale that fits it: a threshold now, an episodic memory consulted at scoring time, or the weights.


The idle judge — learning from your traffic, with no human and no retraining

The feedback loop above needs someone to notice a bad decision. The idle judge removes that requirement for the cases a model can settle on its own.

While the box is serving, it does nothing and costs nothing. When traffic stops, it wakes a 4B GGUF on llama.cpp, takes served exchanges off a queue, asks one yes/no question per axis, and — where it and the detector disagree with enough confidence — writes a labelled cell into the contrastive memory bank that the detector already consults at scoring time. The next request is scored against a bank that learned from the last quiet hour. No retraining, no weights, no human.

It yields to traffic in ~50 ms

The trick is that it is two loops, not one:

  • a watchdog thread does nothing but read a beacon every 50 ms and SIGSTOP / SIGCONT the judge process. It never waits on the judge, so a request arriving in the middle of a prompt evaluation freezes it now — not "at the end of the current item";
  • the work loop claims items and scores axes. It can be stopped at any instruction; its HTTP call simply blocks until the watchdog thaws it. It never needs to know it was interrupted.

Preemption granularity is a property of the watchdog, not of the work. That is why the judge cannot delay a served request.

Memory comes back in three steps, not one

state what it costs how it resumes
running normal
frozen RSS still resident, KV cache intact, pages marked reclaimable first a signal
unloaded nothing re-reading a mmapped GGUF, usually still in page cache

Killing on every request would thrash. Freezing first and unloading only under sustained pressure is what keeps bursty traffic from causing a reload storm.

Promotion is deliberately hard to earn

An automatic writer into the memory bank is a poisoning vector, so a judgement is not enough. To be promoted, an item must:

  1. disagree with the detector — agreement teaches nothing;
  2. clear a confidence margin;
  3. survive a self-consistency re-ask with the question negated — a judge that says yes to both phrasings is not judging.

On top of that there is a daily quota, and every row is stamped reviewer='idle_judge' so a curator can audit or revoke the whole set in one move.

Turn it into a proposal queue

GW_IDLE_JUDGE_AUTOAPPROVE=0 keeps everything the judge finds, but writes nothing: each item waits for a human. Recommended wherever an automatic write into the detector's memory is not acceptable.

Configuration

variable default what it does
GW_IDLE_JUDGE 1 supervisor installed
GW_IDLE_JUDGE_ENABLED 0 actually run it
GW_IDLE_JUDGE_AUTOAPPROVE on 0 → proposal queue only
GW_IDLE_JUDGE_IDLE_S 20 seconds of quiet before it may start
GW_IDLE_JUDGE_UNLOAD_S 300 frozen this long → give the RAM back
GW_IDLE_JUDGE_CORES_FRACTION · _CORES_MAX 0.25 · 8 the share of cores it may use
GW_IDLE_JUDGE_AXES all restrict which axes it judges
GW_IDLE_JUDGE_GPU · _GGUF_GPU off run it on the GPU instead of the CPU

CPU by default, on purpose

The judge is background work. On a box where the GPU is reserved for serving it will run on the CPU — slower, but it never competes with a request. If you want it on the GPU, say so explicitly with the variables above; it will not take the device on its own.


REST API

All routes are mounted under /v1/glad/feedback on G1-Proxy — reached as /gw/v1/glad/feedback/… through the unified port. The Application is resolved from application_id in the body/query or the X-Geodesia-App header (default default).

Create a flag

What it does. Records that a served turn was judged wrong, in plain language. It lands as status: "pending" for a curator, unless GW_FEEDBACK_AUTOAPPROVE=on and the problem maps cleanly to an axis — then it goes straight into the engine.

curl -s http://localhost:8080/gw/v1/glad/feedback \
  -H "Content-Type: application/json" \
  -H "X-Geodesia-App: acme" \
  -d '{
    "region":     "answer",
    "problem":    "fabricated",
    "note":       "invented a citation",
    "prompt":     "Who won the 1923 Paris Review prize?",
    "answer":     "The 1923 Paris Review prize went to …",
    "message_id": "msg_42",
    "session_id": "sess_7"
  }'
import httpx

c = httpx.Client(base_url="http://localhost:8080/gw",
                 headers={"X-Geodesia-App": "acme"}, timeout=30)

# Read the vocabulary instead of hard-coding it — axes can be added per deployment.
schema = c.get("/v1/glad/feedback/schema").json()
print([p["key"] for p in schema["problems"]])

flag = c.post("/v1/glad/feedback", json={
    "region":  "answer",
    "problem": "fabricated",
    "note":    "invented a citation",
    "prompt":  "Who won the 1923 Paris Review prize?",
    "answer":  "The 1923 Paris Review prize went to …",
    "session_id": "sess_7",
}).json()
print(flag["id"], flag["status"])
const H = { "Content-Type": "application/json", "X-Geodesia-App": "acme" }
const base = "http://localhost:8080/gw"

const schema = await fetch(`${base}/v1/glad/feedback/schema`, { headers: H }).then(r => r.json())
console.log(schema.problems.map((p: any) => p.key))

const flag = await fetch(`${base}/v1/glad/feedback`, {
  method: "POST",
  headers: H,
  body: JSON.stringify({
    region: "answer",
    problem: "fabricated",
    note: "invented a citation",
    prompt: "Who won the 1923 Paris Review prize?",
    answer: "The 1923 Paris Review prize went to …",
    session_id: "sess_7",
  }),
}).then(r => r.json())
console.log(flag.id, flag.status)

What comes back — the stored row, with its generated id and current status.

Request fields

Field Type Required Description
region string prompt or answer — which side of the turn was wrong.
problem string A plain-language problem key from /schema. Mapped to an axis server-side.
axis string Name the axis directly. Overrides the problem→axis map. For API callers who know the vocabulary.
verdict string false_negative (should have fired) or false_positive (fired wrongly).
note string Free text for the curator.
prompt / context / answer string The turn itself. Supply them so the correction can be replayed and, later, trained on.
message_id / session_id string Link back to the served turn.
application_id string Same as the X-Geodesia-App header.
scores object The detection payload the turn was served with, so the curator sees what the detector thought at the time.

Read /schema, don't hard-code axes

GET /v1/glad/feedback/schema returns {axes, prompt_axes, answer_axes, problems, problem_to_axis, verdicts, regions}. A deployment can ship extra axes; a client that reads the schema keeps working, one with a hard-coded list quietly drops them.

Review a flag (curator)

What it does. Approves, rejects or re-opens a flag. Approving is what makes a correction real: it names the axis, states which way the detector was wrong, and — optionally — records a contrastive benign twin, the near-identical harmless case that must not flip.

curl -s http://localhost:8080/gw/v1/glad/feedback/fb_9c1f2a7b4e0d6a18/review \
  -H "Content-Type: application/json" \
  -d '{
    "status":   "approved",
    "axis":     "halluc_closedbook",
    "verdict":  "false_negative",
    "reviewer": "anna@acme.com"
  }'
c.post("/v1/glad/feedback/fb_9c1f2a7b4e0d6a18/review", json={
    "status": "approved",
    "axis": "halluc_closedbook",
    "verdict": "false_negative",
    "reviewer": "anna@acme.com",
    # contrastive twin — the benign case that must NOT flip
    "twin_prompt": "Who won the 1923 Nobel Prize in Literature?",
    "twin_answer": "W. B. Yeats.",
    "attack_family": "fabricated_award",
    "weight": 1.0,
})
await fetch(`${base}/v1/glad/feedback/fb_9c1f2a7b4e0d6a18/review`, {
  method: "POST",
  headers: H,
  body: JSON.stringify({
    status: "approved",
    axis: "halluc_closedbook",
    verdict: "false_negative",
    reviewer: "anna@acme.com",
  }),
})
Field Type Required Description
status string approved | rejected | pending.
axis string The axis this correction belongs to.
verdict string false_negative | false_positive.
reviewer string Who decided.
note string Curator note.
weight float How strongly this exemplar should count.
twin_prompt / twin_answer string The contrastive benign twin.
attack_family string Groups related corrections.

Push a correction into the engine

What it does. memory refreshes the episodic exemplar bank and takes effect on the next request — no restart, no training. weights exports the approved corpus and launches the configured trainer as a subprocess; poll the returned job.

# instant: refresh the exemplar bank
curl -s -X POST http://localhost:8080/gw/v1/glad/feedback/retrain \
  -H "Content-Type: application/json" \
  -d '{"mode": "memory", "application_id": "acme"}'

# heavy: export corpus + launch the trainer
JOB=$(curl -s -X POST http://localhost:8080/gw/v1/glad/feedback/retrain \
  -H "Content-Type: application/json" \
  -d '{"mode": "weights"}' | jq -r .job_id)

curl -s "http://localhost:8080/gw/v1/glad/feedback/retrain/status?job_id=$JOB" | jq
import time

job = c.post("/v1/glad/feedback/retrain", json={"mode": "weights"}).json()
while True:
    st = c.get("/v1/glad/feedback/retrain/status", params={"job_id": job["job_id"]}).json()
    print(st["status"], st.get("log_tail", "")[-200:])
    if st["status"] in ("completed", "failed"):
        break
    time.sleep(10)
const job = await fetch(`${base}/v1/glad/feedback/retrain`, {
  method: "POST", headers: H, body: JSON.stringify({ mode: "weights" }),
}).then(r => r.json())

for (;;) {
  const st = await fetch(
    `${base}/v1/glad/feedback/retrain/status?job_id=${encodeURIComponent(job.job_id)}`,
    { headers: H },
  ).then(r => r.json())
  if (st.status === "completed" || st.status === "failed") break
  await new Promise(r => setTimeout(r, 10_000))
}

Full route list

Method Path Purpose
GET /v1/glad/feedback/schema Axis vocabulary + plain-language problem → axis map.
POST /v1/glad/feedback Create a flag.
GET /v1/glad/feedback List / filter the queue: status, application_id, axis, region, limit (≤ 1000), offset.
GET /v1/glad/feedback/stats Pending / approved / rejected / total counts.
POST /v1/glad/feedback/{id}/review Curator action.
DELETE /v1/glad/feedback/{id} Drop a row.
GET /v1/glad/feedback/export The decided corpus as JSONL. Defaults to status=approved.
GET /v1/glad/feedback/bank/status Exemplar-bank version + approved count.
POST /v1/glad/feedback/retrain {mode: "memory" \| "weights", application_id?}.
GET /v1/glad/feedback/retrain/status?job_id=… Job state + log tail.
GET /v1/glad/feedback/retrain/jobs All re-train jobs.
GET /v1/glad/feedback/auto/status Idle-judge state: installed, running, queue depths, disagreements, promotions.
GET PUT /v1/glad/feedback/auto/config Read / patch the idle-judge configuration.
GET /v1/glad/feedback/auto/prompt-preview?axis=… Read-only — the exact prompt the judge will see.
GET /v1/glad/feedback/auto/items?state=…&limit=… What the judge queued, scored or promoted.

The three timescales

The central design decision is that a correction does not go straight into the model. It lands on the timescale that matches its nature:

Immediate · seconds
The threshold
Policy Lens → app policy

The whole population sits in the wrong place. Move the line, hot-reload, effective on the next request. Simulated exactly on your own traffic before it is applied.

Fast · one request
Episodic memory
approved flag → exemplar bank

The population is fine and one case is not. The corrected pattern is recalled at scoring time, exactly, without retraining anything.

Structural · a training run
The weights
approved corpus → re-train

The corrections have accumulated into a pattern worth generalising. Fold them into the detector — deliberately, when someone decides to.

Why the layering is the point — memorize, don't smear the manifold

The detector's geometry is validated out of distribution, and that validation is the product. Every ad-hoc tweak to fix one embarrassing example risks trading a measured, general capability for a local patch — and you find out months later, on traffic you cannot reproduce.

So deployment-specific incidents are memorised in a non-parametric memory that sits beside the model and never touches its weights, and they are folded into the weights only as a deliberate, versioned act. That is what lets one deployment adapt to its own definition of unacceptable without each adaptation degrading everything else.


Where each part runs

The loop spans both halves of the product, and it is worth being explicit about which process owns what:

Stage Runs in Surface
A user or reviewer raises a flag G-1 Studio (Chat, Policy Lens) or any API client POST /v1/glad/feedback
The flag waits for a curator G-1 Studio Feedback workspace
A curator approves / rejects G-1 Studio POST /v1/glad/feedback/{id}/review
The bank is built and consulted at scoring time G-1 Proxy (the gateway, next to the detector) in-process, per request
A re-train is triggered and promoted G-1 Proxy POST /v1/glad/feedback/retrain

One SQLite table (feedback) on the database the gateway already uses — no new datastore, and creating it never touches existing tables, so any deployment picks it up automatically.


1. Raising a flag

Every message in the chat carries a flag control, and it is region-scoped: flagging an assistant message produces region: "answer", flagging a user message produces region: "prompt".

The person flagging picks a plain-language problem — no ML jargon, ever. The system maps that choice to a suggested axis, which the curator can override:

Region Plain-language problem problem key Suggested axis
answer "It is made up / not true" fabricated halluc_closedbook
answer "Contradicts the sources / the document" contradicts_sources halluc_context
answer "Contains dangerous or harmful content" dangerous_answer answer_safety
prompt "Dangerous request not blocked" dangerous_request prompt_safety
prompt "Attempt to bypass the rules" jailbreak_attempt jailbreak
prompt "Document with hidden instructions" hidden_instructions rag_jailbreak
prompt "Contains profanity / offensive language" profane_content profanity
prompt "Off-topic / out of scope" off_topic out_of_scope
prompt "Request is too complex / ambiguous" too_complex prompt_complexity
either "It was wrongly blocked (this is benign)" wrongly_blocked inferred from the score snapshot
either "👍 This is benign — correct" correctly_benign inferred — reinforcement
either "👍 A real threat, correctly blocked" correctly_blocked inferred — reinforcement
either "Other…" (free text) other curator assigns

The flag snapshots its own context — the prompt, any RAG context, the answer, and the detector scores at flag time — so the curator sees the full picture and the corpus is self-contained. The row starts as status: "pending", scoped to the active Application.

Praise is training data

correctly_benign and correctly_blocked confirm a decision that was already right: a confirmed benign becomes a benign anchor (keep allowing this), a confirmed threat a danger anchor. A loop that only ever hears about mistakes drifts, because nothing holds the correct decisions in place.

The vocabulary is served, not hard-coded

GET /v1/glad/feedback/schema returns the axis list, the region grouping and the full problem → axis map. A front-end mirrors it instead of embedding it, so a checkpoint that gains an axis needs no UI change — the three newest axes appeared in the flag menu this way.

Corrections can also be raised from Policy Lens while tuning a threshold on real traffic: Wrongly blocked and Should block push into this same queue, with the axis set to the one under examination so the correction trains the right head.


2. The curator gate

Curators work the queue in the Feedback workspace of G-1 Studio. For each pending flag:

  • Approve — confirm the incident and set the final axis and verdict:
    • false_negative — the detector should have flagged this and didn't. A positive example, and a danger anchor in the memory.
    • false_positive — the detector flagged a benign message. A negative example, and a benign anchor.
  • Reject — noise or abuse. Rejected rows train nothing, ever.
  • Re-open — back to pending.

Only approved rows with a resolved axis reach the export or the memory. This is the human-oversight control the Compliance Platform records — nothing influences scoring or training without a person's explicit approval.

The queue also shows what each detector scored on the flagged axis at the time, so a curator can see how close the call was before ruling on it.

The contrastive twin (optional)

The review form also accepts a benign twin — a superficially similar but harmless counterpart of the flagged incident — plus a cell weight and an attack_family label. With a twin present the memory reasons contrastively: it fires only when the query is more danger-like than the twin, which sharpens the boundary instead of merely pushing it. Omit them and the memory behaves as a plain episodic bank. This is what the v2 bank consumes.

One-human-in-the-loop mode

GW_FEEDBACK_AUTOAPPROVE=on lets a flag whose plain-language problem resolves to an axis go straight into the engine with no curator. other and unresolvable flags still queue.

Understand what you are turning on

Auto-approve removes the only gate between an end user and the live scoring behaviour. It is right for a closed pilot where the flaggers are the security team. It is wrong for anything user-facing — an annoyed user who flags every refusal becomes a training signal. On a regulated deployment, leaving this off is part of your human-oversight evidence.


3. The fast loop — episodic memory

The approved corpus becomes a live, non-parametric memory consulted at scoring time. It is opt-in and off by default: with it disabled, no bank is built or consulted and detection is byte-identical.

At scoring time the detector embeds the current input onto its manifold and compares it with the stored exemplars for the axes of that region:

  • a close match to a false-negative exemplar raises the axis probability — we missed this before, never again;
  • a close match to a false-positive exemplar suppresses it — a known benign pattern we over-flagged;
  • a danger match always wins over a benign one — the memory never suppresses something that also resembles a known dangerous case.

The match is a cosine similarity on the unit sphere with a high floor (τ, default 0.88). That is deliberate: this is exact-pattern recall, not fuzzy generalisation. Generalising is the slow loop's job, and confusing the two is how a memory turns into an unaudited second model.

Per model, per tenant. The bank is built from the shared corpus using G1-Hummingbird's own embedding function. A row whose axis the model does not have is simply skipped. Banks are rebuilt only when the corpus version changes, so a request pays nothing while the corpus is stable.

Per Application. policy.feedback_learning opts a single Application in without flipping the global default, so one tenant can learn from its own corrections while another stays byte-identical.

When a bank match moves a score, the affected axis carries an exemplar_match annotation in the response — the contribution is always auditable:

{
  "axis_energy": {
    "jailbreak": {
      "p_detector": 0.94,
      "threshold": 0.9997,
      "flag": true,
      "exemplar_match": { "verdict": "false_negative", "sim": 0.93 }
    }
  }
}

The v2 bank (Contrastive Safety Memory)

An opt-in second-generation memory that ships alongside v1 — the live path stays byte-identical until you switch it on. Each thing it changes exists because of a specific way v1 fails:

v1 weakness What v2 does instead
A one-sided raise/suppress nudge drifts into over-refusal Contrastive cells: the danger is stored paired with a benign twin, and the cell fires only if the query is more danger-like than the twin by a margin
Nearest-neighbour on a single exemplar is fragile to one mislabelled row A vote over k neighbours (distance-weighted), plus a credibility gate — how much the neighbourhood agrees with the winning class
τ = 0.88 is a magic number with no false-positive guarantee A split-conformal threshold per (region, axis), calibrated on benign serving traffic, with a finite-sample bound on the false-positive lift
Rewriting p_detector and then re-flagging against the old conformal threshold breaks that threshold's guarantee v2 is a separate OR-term: it raises its own flag and never rewrites p_detector
One pooled vector loses compositional intent Per-span matching (MaxSim over spans) plus a lexical surface-form channel

Each cell keeps the originating feedback_id, which is what makes provenance, audit and GDPR deletion of a single incident possible.

Configuration

Variable Default Description
GW_FEEDBACK_BANK off Master switch. on builds and consults the per-model bank; off → never built, zero overhead, byte-identical detection.
GW_BANK_V2 off Use the Contrastive Safety Memory instead of v1. Requires GW_FEEDBACK_BANK=on.
GW_FEEDBACK_BANK_TAU 0.88 v1 cosine floor below which an exemplar is ignored. Higher = stricter exact-pattern recall.
GW_FEEDBACK_BANK_GAIN 1.0 v1: how hard a perfect match pushes the probability.
GW_BANK_ALPHA 0.01 v2 conformal false-positive-lift budget per region (Bonferroni-split across axes).
GW_BANK_K 5 v2 neighbours in the distance-weighted vote.
GW_BANK_CRED_MIN 0.6 v2 minimum neighbourhood agreement required to trust a match.
GW_BANK_CONTRAST_MIN 0.05 v2 margin by which the danger cell must beat its benign twin.
GW_BANK_BMAX 2000 v2 per-(region, axis) memory cap; least-useful cells are evicted first.
# gateway with the contrastive memory on
GW_FEEDBACK_BANK=on GW_BANK_V2=on \
  python -m glad_minimal.gateway.geodesia_gateway --host 0.0.0.0 --port 8800 ...

4. The slow loop — into the weights

The approved corpus downloads as JSONL, one training example per line, ready to aggregate across Applications (and, for a vendor, across customers):

curl -s "http://localhost:8800/v1/glad/feedback/export?status=approved&application_id=acme" \
  -o feedback_approved_acme.jsonl
{
  "id": "fb_9c1f2a7b4e0d6a18",
  "region": "prompt",
  "axis": "jailbreak",
  "label": 1,
  "verdict": "false_negative",
  "prompt": "...",
  "context": "",
  "answer": "",
  "problem": "jailbreak_attempt",
  "note": "slipped past the input screen",
  "application_id": "acme",
  "created_at": "2026-06-26T10:14:33+00:00",
  "source": "human_feedback"
}

label is derived from the verdict: a false_negative on a danger axis → 1, a false_positive0.

Triggering it

# fast loop — rebuild the episodic memory from the approved corpus, right now
curl -s http://localhost:8800/v1/glad/feedback/retrain \
  -H "Content-Type: application/json" -d '{"mode": "memory", "application_id": "acme"}'

# slow loop — export the corpus and launch the configured trainer in the background
curl -s http://localhost:8800/v1/glad/feedback/retrain \
  -H "Content-Type: application/json" -d '{"mode": "weights"}'
{ "job_id": "rt_5f1c9a2e7b04", "mode": "weights", "n_rows": 214,
  "status": "queued", "created_at": "2026-08-05T10:02:11+00:00" }
mode What happens Cost
memory (default) Bumps the bank version so the gateway rebuilds the episodic memory on the next request. Instant, no GPU, no subprocess
weights Exports the approved corpus to JSONL and launches the configured trainer as a background subprocess. A real training run

One weights job runs at a time (a second returns 409). Poll it — the log tail comes with the status:

curl -s "http://localhost:8800/v1/glad/feedback/retrain/status?job_id=rt_5f1c9a2e7b04"

With no trainer configured the corpus is still exported and the job returns status: "prepared" with the path. You never lose the export because the training command was not wired yet.

Promotion

When a weights job finishes successfully and auto-promotion is on (the default), the gateway points the live detector at the freshly trained checkpoint. If the served checkpoint path is a symlink, the swap is atomic and survives the restart; if it is not, the monitor hot-reloads in process — the new model serves immediately, with no downtime, until the next real restart.

Variable Default Description
GW_RETRAIN_CMD (unset) Trainer command, with {corpus} and {out} placeholders. Unset ⇒ export-only (prepared).
GW_RETRAIN_CWD (unset) Working directory for the trainer subprocess.
GW_RETRAIN_DIR <db dir>/retrain Where corpora, logs and outputs are written.
GW_RETRAIN_AUTOPROMOTE 1 Promote the new checkpoint automatically on success.
GW_RETRAIN_RESTART 1 Restart the service after repointing. 0 ⇒ hot-reload only.
GW_RETRAIN_OUT_CKPT (newest *.pt) Explicit checkpoint template, e.g. {out}/model.pt.

Auto-promotion is a real deployment change

With GW_RETRAIN_AUTOPROMOTE on, a completed job replaces the live detector. The UI is warned, not asked — there is no yes/no gate.

That is the right behaviour for a self-improving deployment you control end to end. It is the wrong behaviour when a human is supposed to sign off on every model change. Set it to 0 for regulated deployments and promote deliberately — and record which way you set it, because "how does the model change in production" is a question your FRIA has to answer.


What the loop does not do

Being explicit about the limits is what makes the rest trustworthy.

  • It does not train on your traffic by default. Nothing is learned from a request unless a human flagged it and a curator approved it. Ordinary traffic contributes nothing.
  • It does not learn silently. The bank is off by default; when on, every score it moves is annotated in the response, and every threshold change is a versioned policy write in the audit trail.
  • It does not send anything anywhere. Flags, queue, memory, export and re-train are all local to the deployment's own database and GPU. Feedback is scoped per Application — one tenant never sees another's corpus.
  • It does not generalise from one example. The fast loop is exact-pattern recall with a high similarity floor, by design. If you want generalisation, that is the slow loop, and it is a decision someone makes.
  • It does not silently reshape the detector's validated geometry. That is the whole reason there are three timescales instead of one.

See also

  • Policy Lens — the global lever, with the counterfactual computed before you apply it
  • Detection Axes — the nine axes a correction can be attributed to
  • Human Oversight — how curator approval becomes compliance evidence