---
title: 'AI Safety and Evaluation'
source: 'https://academia.sh/en/courses/ai-safety-and-evaluation'
language: en
updated: '2026-10-21T05:02:27+00:00'
license: 'CC BY-SA 4.0'
lessons: 21
outcomes:
    - 'Separates prompt injection and its variants into attack classes and measures the share the defense catches in each class'
    - 'Builds the filter, marking, masking, permission and approval gate layers and writes the false alarm cost of each'
    - 'Sets up the evaluation set, automatic and human evaluation, regression and production monitoring, and plans a responsible release'
---

# AI Safety and Evaluation

Writing down together the share a defense stops and the share it stops by mistake, and what a system being counted as safe depends on measuring: zero harmful requests stopped at the undefended baseline, the precision, recall and false block rate of a rule filter, precision rising and recall falling as the threshold goes up, stacking a rule filter and a model filter growing false alarms without catching a single new harmful request, what masking removes and what the filter catches in records requesting a secret field, and the residue neither catches, the precision of the approval gate in records requesting a side-effecting tool, the harmful share per source class, the point where recall drops in the adversarial class, the number of classes covered in red teaming and the unknown of the untried class, the record that marking untrusted content neutralizes without blocking anything, the harmful requests stopped and the tasks dropped as permissions narrow, the composition of the evaluation set making the same defense yield three separate numbers, the agreement of a model-as-judge with human labels and its own run-to-run difference, inter-rater agreement changing with the scale and the guideline, which change stays above the run band, how many records later the indicator shows degradation, the bias arising from a blocked user being unable to give a signal, and every measure the published number does not cover remaining a claim.

## Attack Surface

Separating the attack surface into classes that carry a structural attribute profile rather than a working payload, and counting the share the defense catches in each class: zero blocked harmful and zero false alarm in the undefended baseline, writing together the harmful records the rule filter blocks and the clean records it blocks by mistake, separating the direct and indirect forms by source distribution, recall falling as precision rises in a threshold sweep and the threshold at which the easiest point of evasion appears, in records carrying hidden field names what masking removes versus what the filter catches versus the residue nothing catches, among records carrying side-effect tool names how many of those sent to the approval gate are truly harmful, the harmful share per source class and what filtering by source adds, the number of responses carrying a hidden field on the output side, the point in the adversarial class where recall falls relative to the other classes, and in red teaming the number of covered classes versus the passing share and the count of an untested class being unknown rather than zero.

### Prompt Injection

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/prompt-injection

Counting the text that enters a model as an attack surface: in the fictional record set, 93 of 240 records are harmful and 147 are clean, and being harmful lives not in the text but in a structural attribute profile. In the undefended baseline, true positives are 0 and false alarms are 0; the rule-based filter blocks 74 harmful records, wrongly blocks 23 clean ones, and lets 19 harmful ones escape (precision 0.7629, recall 0.7957, false block rate 0.1565). The direct and indirect forms are separated by source distribution: recall is 0.6486 on 37 direct harmful records and 0.8929 on 56 indirect ones. In the second run the same filter gives 0.6988; the resolution of the set is 0.0108 on recall and 0.0068 on false blocks.

The eleven lessons of the previous course measured **whose hands** the tool is in: the definition
moved to the other side, the cost of the envelope was counted, and a definition reported by an
untrusted server was checked item by item. All of that was **outside the interface** — the shell of
the message was counted, not the text inside it. For a language model, however, data and instruction
have no structural boundary: both enter the same context window in the same form. A user's sentence,
the body of a retrieved document, and a value returned by a tool are of the same kind in the model's
eyes. **Prompt injection** names this lack of distinction: the ability of text entering a model to make
it do more than the authority of its path allows.

This lesson does not describe the surface; it **counts** it. In one record set, how many harmful
records are blocked with no defense, how many with a rule-based filter, and how many clean records
does that filter wrongly block? The rule of the course holds from the first line: **a defense that
does not write the share it blocks together with the share it blocks by mistake counts as
unmeasured.**

- **AS1.** The record set, classes, attributes, and defense layers are **fictional**; no real model
  endpoint or incident log is used. Seed **20260218**.
- **AS2.** **This is not an attack lesson.** All code that runs is on the defense side:
  classification, filter, count, and measure. No working payload, template, instruction sequence, or
  bypass routine is written.
- **AS3.** The rule is structural: **an attack is not a text but an attribute profile.** The record text is
  entirely harmless support requests with no relation to the class.
- **AS4.** Class names are **labels**, not procedure recipes. When a new class is needed, a flag is
  added to the attribute list, not a text.
- **AS5.** Input validation was measured in the Secure Coding course and threat modeling in the
  Secure Development Lifecycle course; their procedures are not repeated. Only the surface
  **specific to the language model** is added.
- **AS6.** `precision` and `recall` are used with the classification meaning they have in the
  Machine Learning and Data Science curriculum. A **false alarm** is a clean record counted as
  harmful; a **false negative** is a harmful record that escapes.
- **AS7.** The rule-based filter has no sampling and gives the same number on the same set in every run;
  the second run regenerates the set with a second seed.
- **AS8.** The smallest step of recall is **1/93** and the step of the false block rate is
  **1/147**; no difference smaller than these steps can be measured.

#### An Attack Is a Feature Profile, Not a Text

The usual way to teach an attack surface is to show an example. This course does not, and not as a
matter of style but as a **structural constraint**: the record set has no field called attack text.
What a record carries is seven **flags** — instruction mode, role change
request, hidden field name, side-effect tool name, source stamp mismatch, unusual token
distribution, excessive length. Each is a true-or-false value about the **structure** of a record; none runs.

The record also has a `text` field, and one of six fictional support requests sits there. Those six
sentences are ordinary subscriber questions and **have no relation to the class**: which sentence
falls on which record is decided by a separate draw. Because the text does not determine the class,
the lesson never shows an attack text and the measure rests on the profile. In a real setup the filter reads the text; here what is measured is **a profile that
behaves like the output of that reading**.

```python
# FICTIONAL. The record set, classes, attributes and defense layers are defined inside
# the lesson; no real model endpoint is called. No attack TEXT is written.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# in the real world, well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### The Undefended Baseline and the Rule-Based Filter

The second block prints the composition of the set, then puts two setups side by side. The first is
the **undefended baseline**: no filter, marking, or gate, so nothing is blocked. The second is a
**rule-based filter** that looks at four flags.

```python
from collections import Counter

print("records:", len(RECORDS), " harmful:", sum(1 for k in RECORDS if harmful(k)),
      " clean:", sum(1 for k in RECORDS if not harmful(k)))
print("class:", {c: sum(1 for k in RECORDS if k["class"] == c) for c in CLASSES})
print("source:", {x: sum(1 for k in RECORDS if k["source"] == x) for x in SOURCES})
print("number of classes in which each text appears:",
      [len({k["class"] for k in RECORDS if k["text"] == t}) for t in TEXT])


def undefended(k):
    """Baseline: no filter, marking or gate."""
    return False


print("\nsetup              blocked   false alarm  escaped  precision      recall  false block")
for name, decision in (("undefended", undefended), ("rule filter", rule_filter)):
    m = measure(RECORDS, decision)
    print(f"{name:<15} {m['tp']:>10} {m['fp']:>13} {m['fn']:>8}"
          f" {m['precision']:>10.4f} {m['recall']:>11.4f} {m['false_block_rate']:>12.4f}")

direct = [k for k in RECORDS if harmful(k) and k["source"] == "user"]
indirect = [k for k in RECORDS if harmful(k) and k["source"] != "user"]
print("\nform       harmful     rule caught      recall")
for name, g in (("direct", direct), ("indirect", indirect)):
    caught = sum(1 for k in g if rule_filter(k))
    print(f"{name:<10} {len(g):>7} {caught:>15} {caught / len(g):>11.4f}")

print("\nsource distribution per class")
for c in CLASSES:
    cnt = Counter(k["source"] for k in RECORDS if k["class"] == c)
    print(f"  {c:<20} " + "  ".join(f"{x}={cnt.get(x, 0):>3}" for x in SOURCES))
```

```
records: 240  harmful: 93  clean: 147
class: {'clean': 147, 'indirect_instruction': 24, 'hidden_field_request': 17, 'privilege_escalation': 10, 'source_spoofing': 23, 'corrupted_input': 19}
source: {'user': 137, 'retrieved_document': 72, 'tool_output': 31}
number of classes in which each text appears: [6, 5, 6, 6, 5, 6]

setup              blocked   false alarm  escaped  precision      recall  false block
undefended               0             0       93     0.0000      0.0000       0.0000
rule filter             74            23       19     0.7629      0.7957       0.1565

form       harmful     rule caught      recall
direct          37              24      0.6486
indirect        56              50      0.8929

source distribution per class
  clean                user=100  retrieved_document= 33  tool_output= 14
  indirect_instruction user=  2  retrieved_document= 15  tool_output=  7
  hidden_field_request user= 13  retrieved_document=  4  tool_output=  0
  privilege_escalation user=  3  retrieved_document=  2  tool_output=  5
  source_spoofing      user=  3  retrieved_document= 16  tool_output=  4
  corrupted_input      user= 16  retrieved_document=  2  tool_output=  1
```

The fourth line confirms the design: four of the six texts appear in **all six classes** and two in
five. The text does not carry the class.

The baseline row sets the measurement frame of the course. In the undefended setup, **true positives
are 0**, **false alarms are 0**, and escaped harmful records are **93**. Precision and recall are
zero. The row must be there because the undefended setup has **zero false alarms** too, and if false
alarms are counted alone, doing nothing looks perfect. The number of a defense gains meaning only
when the two columns are read together.

The rule-based filter blocks **74** of the 93 harmful records: recall is 0.7957. Its cost is written
in two columns: **23 clean records** were wrongly blocked, 0.1565 of the 147 clean records.
A precision of 0.7629 means about one in four flagged records is harmless. The 19 escaped
harmful records also stay in the table; the measure of a defense is **what it misses** as much as
what it catches.

#### Direct and Indirect Form

The two forms of prompt injection are separated not by text but by **source**. In the direct form the
harmful content comes from a request the user wrote. The **indirect injection** comes from a place
the user did not write: the body of a retrieved document or a value returned by a tool. The user is the victim here, not the source. The distinction matters for authority: in the indirect form, the
path that brings the text into the system is one the user approved, and it generally runs with broader authority.

The source distribution turns this distinction into a number. **37** of the 93 harmful records come
from the user and **56** from a retrieved document or a tool output — most of the surface is
indirect. The class table divides this among the classes: of the 24 records of the indirect
injection class only 2 come from the user, and of the 23 of the source spoofing class, 3. By
contrast, the hidden field request class (13 of 17) and the corrupted input class (16 of 19) are
predominantly direct.

The filter's number differs between the forms: recall is **0.8929** on the indirect form and
**0.6486** on the direct form. The difference is 0.2443, more than twenty times the resolution of
the set, so it is measurable. The reason is in the rule list: one of the four rules is
`source_stamp_mismatch`, a flag that is frequent in the indirect classes and rare in the direct ones.
The filter is **markedly weaker on one side of the surface than on the other**, and a single average
recall hides this: the overall rate swallows the difference between the subsets.

#### The Band of the Measure

The rule-based filter contains no sampling; on the same set it gives exactly the same five numbers
in every run. So the second run changes not the filter but the **set**: the same definition is
regenerated with a second seed.

```python
def build_set(seed):
    """Same definition, another seed: the record set is regenerated."""
    ks = []
    for i in range(240):
        r = rng(seed + 6151 * i)
        c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
        source = SOURCES[pick(r(), SOURCE_SHARE[c])]
        feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
        ks.append({"no": i + 1, "class": c, "source": source, "features": feat,
                   "text": TEXT[int(r() * len(TEXT))], "length": 40 + int(r() * 260)})
    return ks


SECOND = build_set(20260219)
print("run      harmful  clean     blocked   false alarm      recall  false block")
for name, ks in (("first ", RECORDS), ("second", SECOND)):
    m = measure(ks, rule_filter)
    print(f"{name}  {sum(1 for k in ks if harmful(k)):>8} {sum(1 for k in ks if not harmful(k)):>6}"
          f" {m['tp']:>11} {m['fp']:>13} {m['recall']:>11.4f}"
          f" {m['false_block_rate']:>12.4f}")

print("\nform split in the second run")
for name, cond in (("direct  ", lambda k: k["source"] == "user"),
                   ("indirect", lambda k: k["source"] != "user")):
    g = [k for k in SECOND if harmful(k) and cond(k)]
    caught = sum(1 for k in g if rule_filter(k))
    print(f"  {name}  harmful {len(g):>3}  caught {caught:>3}  recall {caught / len(g):.4f}")
print("\nresolution: recall step", round(1 / 93, 4),
      " false block step", round(1 / 147, 4))
```

```
run      harmful  clean     blocked   false alarm      recall  false block
first         93    147          74            23      0.7957       0.1565
second        83    157          58            31      0.6988       0.1975

form split in the second run
  direct    harmful  36  caught  18  recall 0.5000
  indirect  harmful  47  caught  40  recall 0.8511

resolution: recall step 0.0108  false block step 0.0068
```

The second run shows why a single number is not enough. The same filter, the same rules, the
same generator; only the draw of the set changed, and recall went from 0.7957 to **0.6988** and the
false block rate from 0.1565 to **0.1975**. The reason is not a worse filter but a changed
composition: the second set has 83 harmful records instead of 93, and the
corrupted input class, where the filter is weak, carries 25 records instead of 19. **The number of a
defense is a property of the set it was measured on.** Every rate in this course is written next to its set.

Only **the ordering between forms** holds: in the second run too, the
indirect form (0.8511) is markedly higher than the direct form (0.5000). When the set changed, the
rates slid and the relation stayed. What is independent of the run is the **position of the two
subsets relative to each other**. The last line writes the lower bound of the band: no smaller difference
is a finding on this set.

#### Summary

- Prompt injection arises from data and instruction standing without distinction in the same context
  window; whatever the source, the text enters in the same form.
- An attack is not a text but an **attribute profile**: the record text is a harmless support request,
  and four of the six texts appear in all six classes.
- In the undefended baseline, true positives are 0, false alarms are 0, and 93 harmful records escape;
  reading false alarms alone makes doing nothing look perfect.
- The rule-based filter blocks 74 of the 93 harmful records (recall 0.7957), blocks 23 clean records
  (false block rate 0.1565), and has a precision of 0.7629.
- The direct form has 37 harmful records and the indirect form 56; recall is 0.8929 on the indirect and
  0.6486 on the direct, and a single average hides this.
- The resolution of the set is 0.0108 on recall and 0.0068 on the false block rate; in the second run
  the rates slide and the ordering between forms holds.

#### Next Step

The harshest side of the rule-based filter is its sharpness: a flag is present or absent, with no
intermediate value, and every structure missing from the list passes silently. The
opposite approach produces a **score** and decides with a **threshold**, and that threshold is a
setting. The next lesson sweeps this setting and treats guardrail bypass not as a procedure but as
**a threshold question**: as the threshold rises, precision goes up and recall goes down, and it
counts which class the defense collapses on first and at which threshold the point most open to
bypass appears.

### Jailbreak Techniques

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/jailbreak-techniques

Measuring guardrail bypass as a threshold question, not a procedure: on the same 240-record fictional set, as the threshold rises from 0.30 to 0.80, precision climbs from 0.7568 to 0.9787, recall falls from 0.9032 to 0.4946, and the false block rate drops from 0.1837 to 0.0068. Raising the threshold is a one-to-one trade up to 0.50 (7 for 7, 11 for 12) and 2.33 and 3.25 above it; this is the region most open to bypass. Per-class recall is read separately, and the first class to collapse is corrupted input (0.6842 to 0.2105). In the second run, recall is unchanged at thresholds 0.40 and 0.60 while the decision of 13 and 9 records changes.

The previous lesson measured the rule-based filter: it looks at four flags, blocks 74 of 93 harmful
records, and wrongly blocks 23 clean ones. Its decision is sharp — a flag is present or absent. A
filter that produces a score puts a **threshold** in between, and because the threshold is a setting,
it is in the hands of whoever operates the defense. Where it is placed looks like a configuration
detail, yet it determines how much of the surface stays open.

**Guardrail bypass** is not treated here as a procedure. No bypass method, template, or example is
written; the only things written are the **class name and the share the defense catches in that
class**. The question: as the threshold rises, how do precision and recall move in opposite
directions, which class is the first to collapse, and above which threshold does the
region most open to bypass begin?

- **AS9.** The record set, classes, and defense layers are **fictional**; the previous lesson's definition
  continues unchanged. Seed **20260218**.
- **AS10.** **This is not an attack lesson.** All code that runs is on the defense side: scoring,
  threshold, filter, and count. No working bypass method, template, or instruction sequence is written.
- **AS11.** An attack is not a text but an **attribute profile**; the text of the records is
  entirely harmless support requests and has no relation to the class.
- **AS12.** The model-based filter is a **simulator**: it produces a weighted sum, a source-dependent
  addition, and a narrow share of uncertainty. No real classifier is trained.
- **AS13.** The swept thresholds are five points; intermediate values were not measured, and the
  curve is **not assumed** to be flat between them.
- **AS14.** Per-class recall is divided by that class's record count; the privilege
  escalation class has 10 records, and there one record is worth 0.1000.
- **AS15.** In the second run the set is unchanged; only the uncertainty share of the score is
  redrawn with the second seed. What is measured is the **run variability of the model**.
- **AS16.** The smallest recall step is **1/93 = 0.0108** and the false block rate step is
  **1/147 = 0.0068**.

#### The Threshold Is Not a Defense, It Is a Trade-Off Point

The model-based filter gives every record a score: a weighted sum of the flags, an addition that
depends on the source, and a narrow uncertainty share. The score is not a decision; the decision
arises with the threshold. When the threshold is low the filter flags many records, when it is high
it flags few. This setting determines not how good the defense is but **where it stands**,
and it moves two numbers in opposite directions at once.

```python
# FICTIONAL. The record set, classes, attributes and defense layers are defined inside
# the lesson; no real model endpoint is called. No attack TEXT is written.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# in the real world, well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### The Sweep and the Rate of Change

The second block sweeps five thresholds, then counts at each step **what is traded for what**: when
the threshold rises one step, how many more harmful records escape and how many clean records are
released in return.

```python
THRESHOLDS = (0.30, 0.40, 0.50, 0.60, 0.80)
print("threshold      flagged     blocked   false alarm  escaped  precision      recall  false block")
for th in THRESHOLDS:
    m = measure(RECORDS, lambda k, th=th: model_filter(k, th))
    print(f"{th:<9.2f} {m['tp'] + m['fp']:>12} {m['tp']:>11} {m['fp']:>13} {m['fn']:>8}"
          f" {m['precision']:>10.4f} {m['recall']:>11.4f} {m['false_block_rate']:>12.4f}")

print("\neffect of raising the threshold")
print("step                extra escapes       clean released ratio false alarms at upper threshold")
for lo, hi in zip(THRESHOLDS, THRESHOLDS[1:]):
    m_lo = measure(RECORDS, lambda k, th=lo: model_filter(k, th))
    m_hi = measure(RECORDS, lambda k, th=hi: model_filter(k, th))
    d_escaped, d_freed = m_hi["fn"] - m_lo["fn"], m_lo["fp"] - m_hi["fp"]
    print(f"{lo:.2f} -> {hi:.2f} {d_escaped:>20} {d_freed:>20} {d_escaped / d_freed:>5.2f} {m_hi['fp']:>31}")

print("\nsize of the differences relative to the resolution of the set")
print("step         recall difference steps false block difference steps")
for lo, hi in zip(THRESHOLDS, THRESHOLDS[1:]):
    m_lo = measure(RECORDS, lambda k, th=lo: model_filter(k, th))
    m_hi = measure(RECORDS, lambda k, th=hi: model_filter(k, th))
    d_recall = m_lo["recall"] - m_hi["recall"]
    d_false = m_lo["false_block_rate"] - m_hi["false_block_rate"]
    print(f"{lo:.2f} -> {hi:.2f} {d_recall:>17.4f} {d_recall / (1 / 93):>5.1f} {d_false:>22.4f}"
          f" {d_false / (1 / 147):>5.1f}")
```

```
threshold      flagged     blocked   false alarm  escaped  precision      recall  false block
0.30               111          84            27        9     0.7568      0.9032       0.1837
0.40                97          77            20       16     0.7938      0.8280       0.1361
0.50                74          66             8       27     0.8919      0.7097       0.0544
0.60                64          59             5       34     0.9219      0.6344       0.0340
0.80                47          46             1       47     0.9787      0.4946       0.0068

effect of raising the threshold
step                extra escapes       clean released ratio false alarms at upper threshold
0.30 -> 0.40                    7                    7  1.00                              20
0.40 -> 0.50                   11                   12  0.92                               8
0.50 -> 0.60                    7                    3  2.33                               5
0.60 -> 0.80                   13                    4  3.25                               1

size of the differences relative to the resolution of the set
step         recall difference steps false block difference steps
0.30 -> 0.40            0.0752   7.0                 0.0476   7.0
0.40 -> 0.50            0.1183  11.0                 0.0817  12.0
0.50 -> 0.60            0.0753   7.0                 0.0204   3.0
0.60 -> 0.80            0.1398  13.0                 0.0272   4.0
```

The sweep table does not show a one-directional improvement. As the threshold rises from 0.30 to
0.80, precision climbs from 0.7568 to **0.9787**, the false block rate drops from 0.1837 to
**0.0068**, and the 111 flagged records fall to 47. The same movement takes recall from 0.9032 to
**0.4946**: at the highest threshold the defense **misses 47 of the 93 harmful records**. A setup that
reports only precision looks nearly perfect at 0.80, and it stays that way as long as the escaped
column is not written.

The second table turns the choice of threshold from a preference into a calculation. From 0.30 to
0.50 the trade is **one to one**: 7 clean records for 7 harmful ones, then 12 for 11. Above 0.50 the
ratio breaks: in the 0.50 → 0.60 step, 3 clean records cost **7 harmful ones** (2.33); in the 0.60 →
0.80 step, 4 clean records cost **13 harmful ones** (3.25).

**This is the region most open to bypass.** A defense that raises the threshold above 0.50 to reduce
false alarm complaints releases two to three times as many harmful records as the clean traffic it
gains, and does so **without any indicator getting worse**: precision rises, the false block rate
falls, the flagged records decrease. Here the bypass is opened not by a method but by the setting of
whoever operates the defense.

The last column writes why this region is the most expensive. At threshold 0.60 the false alarms are
**5 records**, 0.0340 of 147 clean ones; going from there to 0.80 gains 4 clean records and releases
**13 harmful ones**. The place where loosening is most expensive is where false alarms are **already
the fewest**: when the clean traffic to be gained runs out, each step only enlarges the escaped
harmful records.

The third table says whether these differences are **measurable**. The smallest of the recall
differences is 0.0752, seven times the step of the set (0.0108); the smallest of the false block
differences is 0.0204, three times the step (0.0068). **No step of the sweep falls below the
resolution**, so all four steps are real measurements. The closest to the floor is the 0.50 →
0.60 step: measurable, but too thin to build a policy on.

#### Where the Defense Collapses First

Overall recall is an average and puts all five harmful classes in one pot. The third block
separates it per class and adds the share of the rule-based filter in the same classes.

```python
THRESHOLDS = (0.30, 0.40, 0.50, 0.60, 0.80)
print("per-class recall: the share the defense caught in that class")
print(f"{'class':<20} {'records':>7} {'rule':>7} " + " ".join(f"{th:>6.2f}" for th in THRESHOLDS))
for c in CLASSES[1:]:
    g = [k for k in RECORDS if k["class"] == c]
    rule_recall = sum(1 for k in g if rule_filter(k)) / len(g)
    print(f"{c:<20} {len(g):>7} {rule_recall:>7.4f} " +
          " ".join(f"{sum(1 for k in g if model_filter(k, th)) / len(g):>6.4f}" for th in THRESHOLDS))


def second_score(k):
    """Same record, second run: the noise of the score is redrawn with the second seed."""
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(20260219 ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


print(f"\n{'threshold':<9} {'recall':>21} {'steps':>5} {'false block rate':>21} {'steps':>5}"
      f" {'changed decisions':>17}")
for th in THRESHOLDS:
    m1 = measure(RECORDS, lambda k, th=th: model_filter(k, th))
    m2 = measure(RECORDS, lambda k, th=th: second_score(k) >= th)
    d_recall = abs(m2["recall"] - m1["recall"])
    d_false = abs(m2["false_block_rate"] - m1["false_block_rate"])
    changed = sum(1 for k in RECORDS if model_filter(k, th) != (second_score(k) >= th))
    print(f"{th:<9.2f} {m1['recall']:>10.4f} {m2['recall']:>10.4f} {d_recall / (1 / 93):>5.1f}"
          f" {m1['false_block_rate']:>10.4f} {m2['false_block_rate']:>10.4f}"
          f" {d_false / (1 / 147):>5.1f} {changed:>17}")
```

```
per-class recall: the share the defense caught in that class
class                records    rule   0.30   0.40   0.50   0.60   0.80
indirect_instruction      24  1.0000 1.0000 1.0000 0.9583 0.8333 0.6667
hidden_field_request      17  0.9412 0.9412 0.8824 0.7059 0.5882 0.4706
privilege_escalation      10  0.9000 0.9000 0.9000 0.8000 0.6000 0.4000
source_spoofing           23  0.8696 0.9565 0.8696 0.8261 0.8261 0.6087
corrupted_input           19  0.2632 0.6842 0.4737 0.2105 0.2105 0.2105

threshold                recall steps      false block rate steps changed decisions
0.30          0.9032     0.9140   1.0     0.1837     0.1837   0.0                 5
0.40          0.8280     0.8280   0.0     0.1361     0.1293   1.0                13
0.50          0.7097     0.7419   3.0     0.0544     0.0544   0.0                11
0.60          0.6344     0.6344   0.0     0.0340     0.0136   3.0                 9
0.80          0.4946     0.4624   3.0     0.0068     0.0068   0.0                 5
```

The class table shows what a single recall number hides. At threshold 0.50, while overall recall is
0.7097, the indirect injection class is at 0.9583 and the corrupted input class at **0.2105**: the
difference is 0.7478, and the defense is nearly complete on the first and nearly absent on the
second. Corrupted input is the class that **collapses first** — it falls to 0.4737 already at 0.40,
hits bottom at 0.50, and stays there for three thresholds. The reason is in the weight table: the
weights of the two flags that class carries do not reach the threshold. The share of the
rule-based filter is also low there (0.2632), because those two flags are not in the rule list at
all. That class itself is handled in a separate lesson.

The rule column gives one more comparison: at threshold 0.50 the model-based filter is below the
rule-based filter in all five classes and overall (0.7097 against 0.7957). A higher precision does not
mean a better defense; **the two numbers go in separate directions, and which to choose is a decision,
not a measurement.**

The right question is not "which is better" but "at which threshold does which dominate". The triple
of the rule-based filter is 0.7629 / 0.7957 / 0.1565. The only point where the model-based filter
beats it on **all three at once** is 0.40: 0.7938 / 0.8280 / 0.1361. Such a point is not
guaranteed; on another set, no dominant threshold is also ordinary.

The second-run table gives the band on both axes at once; the `steps` columns divide the difference
by the step of the set. At thresholds 0.40 and 0.60, recall comes out **exactly the same in both
runs**, yet at the same thresholds **the decision of 13 and 9 records changes**: the total stands,
the individual records do not. In recall, 0.30 moves one step, and 0.50 and 0.80 move three steps; in
the false block rate, 0.40 moves one step and 0.60 three. Movements of one step are **exactly on the
band and do not count as findings**; differences of three steps are measurable but do not carry a
threshold choice. The result on both axes is the same: on this set, choosing the threshold **with
decimal precision** is meaningless.

#### Summary

- The threshold is not a defense but a trade-off point: as it rises, precision goes from 0.7568 to
  0.9787, recall falls from 0.9032 to 0.4946, and the false block rate drops from 0.1837 to 0.0068.
- The rate of change of raising the threshold is one to one up to 0.50; above it, each clean record
  released costs 2.33 and 3.25 harmful ones.
- The region most open to bypass is above 0.50 and opens **without any indicator getting worse**;
  where it is most expensive is where false alarms are already the fewest (5 records at 0.60, 0.0340).
- Per-class recall opens what the average hides: at threshold 0.50, indirect injection is 0.9583 and
  corrupted input 0.2105; the class that collapses first is corrupted input, and the rule-based
  filter is weak there too.
- No step of the sweep falls below the resolution (smallest difference: 7 steps in recall, 3
  in the false block rate); all four steps are real measurements.
- In the second run recall stays the same at 0.40 and 0.60 but the decision of 13 and 9 records
  changes; movements of one step are exactly on the band and do not count as findings.

#### Next Step

In these two lessons the measure of the defense was tied to one question: was the record
blocked or not. Some harm has nothing to do with blocking. When a record passes, what is lost is
sometimes not an action but a **piece of information**: the system instruction itself, a subscriber's
identity, or an access string. There the defense changes too — instead of blocking, removing the
field, that is **masking**. The next lesson only counts: how many records carry a hidden field name,
from how many of them masking removes the field, how many the filter catches, and how many records
are left that **none of them catches**.

### Data Exfiltration

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/data-exfiltration

Measuring system instruction disclosure and sensitive data disclosure by counting alone: in the 240-record fictional set, 32 records carry a hidden field name (23 harmful, 9 clean), split across field classes as access string 8, contact 10, subscriber identity 9, address 5. The exfiltration surface is 35 records, 26 harmful. The full rule filter blocks 25 and masking adds zero; with the hidden field rule removed, the narrow filter blocks 16 and masking removes the field from 9. In both setups the residue that none catches is 1 record: it carries no flag, scores 0.0088, and is not flagged even at the lowest threshold. Measured on its own as a decision, masking gives precision 0.7188, recall 0.2473, and a false block rate of 0.0612; in the second run the residue drops to 0.

The previous two lessons tied the measure of the defense to a single question: was the record blocked
or not. Some harm has nothing to do with blocking. When a record passes, what is lost is sometimes
not an action but a **piece of information** — the system instruction given to the model, a
subscriber's identity, an address, or an access string. This loss cannot be undone: a blocked
operation can be retried, a disclosed field cannot be taken back.

In this lesson **data exfiltration** is only **counted**. No exfiltration procedure, query form, or
disclosure example is written; what is written is four numbers: how many records carry a hidden field
name, from how many of them masking removes the field, how many the filter catches, and how many
records are left that **none of them catches**. That last number is the real finding of the lesson.

- **AS17.** The record set, classes, secret field classes, and defense layers are
  **fictional**; no real personal data or access string is used. Seed **20260218**.
- **AS18.** **This is not an attack lesson.** All code that runs is on the defense side: counting,
  masking, filter. No working exfiltration procedure or query form is written.
- **AS19.** An attack is not a text but an **attribute profile**; the text of the records is entirely
  harmless support requests and has no relation to the class.
- **AS20.** Personal data and the masking procedure were measured in the Application Security and
  DevSecOps and the Machine Learning and Data Science curricula; they are not repeated. Here the count
  covers only **the data going to the model**.
- **AS21.** Masking is **not a block**: it does not stop the record, it only removes the field.
  Measuring it as a decision is an approximation, which the lesson writes separately.
- **AS22.** Which field class a record requests is derived from the record number; this is not a
  detection but a distribution built to split the count.
- **AS23.** The exfiltration surface is the union of two conditions: records that carry the hidden
  field name flag and records in the hidden field request class. They overlap; neither contains the other.
- **AS24.** The smallest step of recall is **1/93 = 0.0108**, and the step of the false block rate is
  **1/147 = 0.0068**. A one-record residue sits exactly on this band.

#### Exfiltration Is a Loss, Not an Action

Exfiltration has two targets, both in the system's own context. The first is **system
instruction disclosure**: the instruction given to the model carried into the output. The harm
is not that the text is secret but that **the rules the defense rests on** become visible. The second
is **sensitive data disclosure**: a subscriber record, address, or access string that entered
the context showing up in the response. The record set collects both in one structural flag, `hidden_field_name`, and splits it into four
field classes: identity, contact, location, access.

```python
# FICTIONAL. The record set, classes, secret field classes and defense layers are defined
# inside the lesson; no real personal data is used. No attack TEXT is written.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# in the real world, well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- DEFENSE 4: personal data masking (count only)
SECRET_FIELD = {"subscriber_id": "identity", "contact_info": "contact", "address": "location",
                "access_string": "access"}


def redact(k):
    """Fictional: in records that carry a hidden field name, the field is removed."""
    return {"redacted": k["features"]["hidden_field_name"]}


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### How Many Records Request It

The second block counts the surface: how many records carry a hidden field name, which field class
each requests, and how many of them are actually harmful.

```python
FIELDS = list(SECRET_FIELD)


def field_class(k):
    """Which field class a record with a hidden field name requests (fictional, bound to the record)."""
    return FIELDS[trace_id(k["no"]) % len(FIELDS)]


carriers = [k for k in RECORDS if k["features"]["hidden_field_name"]]
print("records with a hidden field name:", len(carriers),
      " harmful:", sum(1 for k in carriers if harmful(k)),
      " clean:", sum(1 for k in carriers if not harmful(k)))
print("\nfield class      requests  harmful  clean  category")
for a in FIELDS:
    g = [k for k in carriers if field_class(k) == a]
    print(f"{a:<16} {len(g):>8} {sum(1 for k in g if harmful(k)):>8}"
          f" {sum(1 for k in g if not harmful(k)):>6}  {SECRET_FIELD[a]}")

print("\nclass distribution of the flagged records")
for c in CLASSES:
    n = sum(1 for k in carriers if k["class"] == c)
    if n:
        print(f"  {c:<20} {n:>3}")

print("\nif masking is measured on its own as a decision")
m = measure(RECORDS, lambda k: redact(k)["redacted"])
print(f"  removed fields {m['tp'] + m['fp']}  of which harmful {m['tp']}"
      f"  clean {m['fp']}  harmful untouched {m['fn']}")
print(f"  precision {m['precision']}  recall {m['recall']}"
      f"  false block rate {m['false_block_rate']}")
```

```
records with a hidden field name: 32  harmful: 23  clean: 9

field class      requests  harmful  clean  category
subscriber_id           9        6      3  identity
contact_info           10        6      4  contact
address                 5        4      1  location
access_string           8        7      1  access

class distribution of the flagged records
  clean                  9
  indirect_instruction   4
  hidden_field_request  14
  privilege_escalation   2
  source_spoofing        2
  corrupted_input        1

if masking is measured on its own as a decision
  removed fields 32  of which harmful 23  clean 9  harmful untouched 70
  precision 0.7188  recall 0.2473  false block rate 0.0612
```

Thirty-two records carry a hidden field name, and **9 are clean**. Those nine are not an
attack: they are ordinary requests from people asking for their own address or verifying their
own subscriber identity. A hidden field name is not an accusation but a **count**: the flag
does not show intent, it only says which field the record touches.

The flag is not evenly spread across classes either. Fourteen come from the hidden field request
class, but nine are clean and the other nine come from four different harmful classes.
Exfiltration is not the behavior of a single class but a result that can be a **by-product** of many;
a defense aimed at one class never sees part of the surface.

The field class table does not equalize cost. Eight requests go to the `access_string` class and
seven are harmful. Disclosing a contact field is upsetting, disclosing an access string is
**permanent**: the string stays valid until renewed, and renewal depends on the disclosure being
noticed. A count's size is not the harm's size.

The last section measures masking as a decision and shows why that is misleading. Precision 0.7188,
recall **0.2473**, false block rate 0.0612. Recall being this low does not make masking a poor
defense: it **never tries** to catch 70 of the 93 harmful records, because its job is to remove
the field, not to block. A defense put on the wrong measure misleads more than one not measured.

#### What the Layer Adds and the Residue

The third block builds the exfiltration surface and applies the layers in order: the filter
blocks first, masking removes the field on the remainder, and what is left is the **residue**. The setup
runs twice: with the full rule filter, and with a narrow filter whose hidden field rule was
removed.

```python
NARROW = ("instruction_mode", "side_effect_tool_name", "source_stamp_mismatch")
SURFACE = [k for k in RECORDS
           if k["features"]["hidden_field_name"] or k["class"] == "hidden_field_request"]
HARMFUL = [k for k in SURFACE if harmful(k)]
print("exfiltration surface:", len(SURFACE), " of which harmful:", len(HARMFUL))
print(f"\n{'setup':<30} {'blocked':>8} {'masked':>11} {'residue':>8}"
      f" {'overall recall':>18}")
for name, rules in (("full rule filter", None), ("hidden field rule removed", NARROW)):
    decision = rule_filter if rules is None else (lambda k, rl=rules: rule_filter(k, rl))
    stopped = [k for k in HARMFUL if decision(k)]
    extra = [k for k in HARMFUL if not decision(k) and redact(k)["redacted"]]
    print(f"{name:<30} {len(stopped):>8} {len(extra):>11}"
          f" {len(HARMFUL) - len(stopped) - len(extra):>8} {measure(RECORDS, decision)['recall']:>18.4f}")

residue = [k for k in HARMFUL if not rule_filter(k) and not redact(k)["redacted"]]
for k in residue:
    print("\nresidue record  no", k["no"], " class", k["class"], " source", k["source"])
    print("  flagged features:", [o for o in FEATURES if k["features"][o]] or "none")
    print("  score", round(score(k), 4), " flagged at the lowest threshold (0.30):",
          model_filter(k, 0.30))
    print("  text:", k["text"])


def build_set(seed):
    """Same definition, another seed: the second run for the band of the residue."""
    ks = []
    for i in range(240):
        r = rng(seed + 6151 * i)
        c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
        source = SOURCES[pick(r(), SOURCE_SHARE[c])]
        feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
        ks.append({"no": i + 1, "class": c, "source": source, "features": feat,
                   "text": TEXT[int(r() * len(TEXT))], "length": 40 + int(r() * 260)})
    return ks


print(f"\n{'run':<8} {'flagged':>8} {'surface':>7} {'harmful':>8} {'narrow filter':>13}"
      f" {'masking':>10} {'residue':>8}")
for name, ks in (("first", RECORDS), ("second", build_set(20260219))):
    y = [k for k in ks if k["features"]["hidden_field_name"] or k["class"] == "hidden_field_request"]
    z = [k for k in y if harmful(k)]
    d = [k for k in z if rule_filter(k, NARROW)]
    e = [k for k in z if not rule_filter(k, NARROW) and redact(k)["redacted"]]
    print(f"{name:<8} {sum(1 for k in ks if k['features']['hidden_field_name']):>8} {len(y):>7}"
          f" {len(z):>8} {len(d):>13} {len(e):>10} {len(z) - len(d) - len(e):>8}")
```

```
exfiltration surface: 35  of which harmful: 26

setup                           blocked      masked  residue     overall recall
full rule filter                     25           0        1             0.7957
hidden field rule removed            16           9        1             0.6989

residue record  no 16  class hidden_field_request  source user
  flagged features: none
  score 0.0088  flagged at the lowest threshold (0.30): False
  text: mahallede kesinti ne zaman bitecek

run       flagged surface  harmful narrow filter    masking  residue
first          32      35       26            16          9        1
second         25      26       16            10          6        0
```

The first row is an example of the second claim of the course, written by name: **adding
masking on top of the full rule filter adds zero.** The filter already blocks 25 of the 26 harmful
records, because `hidden_field_name` is one of its four rules. No record is left for masking to touch,
yet it still **removes the field from 9 clean records**, and their responses come back incomplete. The added layer **imposes a cost and catches no new harmful record**; a layer's value
comes not from itself but from **what the layer beneath it leaves**.

The second row confirms this. With the hidden field rule removed, the filter blocks 16
of the 26, overall recall falls from 0.7957 to 0.6989, and **9 records** appear for masking to touch.
The same defense on the same set protects 0 or 9 records, depending on the layer beneath it. **The
count of one layer is meaningless alone**; it can be read only together with the rest of the stack.

The third number is the same in both setups: **the residue is 1**. Record 16 is from the hidden field
request class, comes from the user, and **carries no flag**. Its score is 0.0088, so it is not flagged
even at the lowest threshold; the rule filter finds no flag to look at, masking no field to remove.
Its text does not help either: an ordinary outage question. **A harmful record whose profile cannot
be told apart from clean ones is residue no layer can catch.**

The last table writes that the **size of the residue cannot be measured**. One record is exactly one
step on the recall axis (0.0108), exactly on the resolution of the set, not above it. On
the set produced with the second seed, the same setup finds the residue **0**. The two runs
say: the residue is not zero, but how many it is cannot be said with this set. Knowing it is
not zero is enough, because calling a system safe depends not on a zero residue but on
**writing down what it is**.

#### Summary

- The loss in exfiltration cannot be undone: a blocked operation can be retried, a disclosed field
  cannot be taken back; so the measure is a count, not a block.
- 32 records carry a hidden field name and 9 are clean; the flag is a count, not an accusation, and
  with fourteen records from the hidden field request class it spreads into four harmful classes.
- Across field classes, count and harm are not proportional: the smallest group, the access string
  (8 requests, 7 harmful), carries the most permanent result.
- Measured as a decision, masking has recall 0.2473, but this is a wrong measure, not a fault of
  masking; its job is to remove the field, not to block.
- On top of the full rule filter, masking adds 0, at the cost of the field removed from 9 clean
  records; with the hidden field rule removed, the same layer protects 9 records.
- The residue is 1 record: it carries no flag, scores 0.0088, and has a profile indistinguishable
  from clean records; in the second run it is 0, so its size cannot be measured with this set.

#### Next Step

In three lessons the surface was read from one place: **the text entering the model**. The two
forms of injection were separated by source, the filter threshold was swept, records requesting a
hidden field were counted, and the residue none catches was written down. All these measures
concerned what the model **reads**. A system has more than reading: the **tools** it can call,
their side effects and the authority behind them. The **training data** has not
been asked about at all. The harm of such a record comes not from carrying a piece of information but
from **triggering an action**, and the defense changes too: instead of filtering, **asking**. The next
lesson counts the records that carry a side-effect tool name and measures the precision of the human
approval gate.

### Tool Misuse

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/tool-misuse

Counting agent authority on the defense side: of 240 records, 27 carry the side-effect tool name flag; the no-gate baseline sends 0 requests to a human, the gate sends these 27 and 24 turn out to be harmful, so the gate has precision 0.8889 and recall 0.2581. The gate sees 8 of the 10 privilege escalation records and 1 of the 17 hidden field request records. A gate widened by role mode raises precision to 0.9138 and recall to 0.5699; a gate widened by source drops precision to 0.5586 and sends 49 clean records to a human. A gate placed on top of the rule filter moves none of the four numbers: the rule already flagged all 27 records going to the gate. Across two runs the gate's precision comes out 0.8889 and 0.6471.

In three lessons the attack surface was read through the **text entering the model**: whether a record
carrying the instruction mode came directly or from an indirect source, how far the threshold held in
the guardrail bypass class, and the residue left after masking in records carrying a hidden field
name. In all three the model was a **reader**: it took the text, produced a response, and the job
ended there.

A model that can call tools is not only a reader. If it can write a record, close a request, or
assign a field crew, what the model holds is not text but **authority**, and spending that authority
in the wrong place is a surface independent of how the text is read. This lesson counts that surface:
how many records carry a side-effect tool name, how many of them the human approval gate sends to a
human, how many of those sent are actually harmful, and what the **precision of the gate** is.

In the Model Context Protocol and Tool Ecosystem course, three definitions reported by an untrusted
server were audited and gave six findings; the same audit also produced two findings on three trusted
servers. That count was on the **declaration** side: it audited the party that says what a tool is.
Here the declaration is treated as closed, and what is counted is **the request itself**. The
definition audit is not repeated; its numbers are inputs.

- **AS41.** The record set, the classes, and the defenses are **fictional**; no real system, server,
  or tool is called. Seed **20260218**.
- **AS42.** An attack is not a text but an **attribute profile**. The text of the records is entirely
  harmless support requests and has **no relation to the class**; the class lives only in the
  attribute profile. No working exploit, payload, or privilege escalation procedure is written.
- **AS43.** `side_effect_tool_name` is a **flag**, not a tool name. Which tool carries which side
  effect is not counted here; what is counted is whether a record carrying the flag goes to the gate.
- **AS44.** The baseline is the **no-gate setup**: no request goes to a human, blocked harmful is
  **0**, false alarm is **0**.
- **AS45.** The gate is not a block but a **routing**; its cost is not blocked traffic but **the
  number of records going to a human**.
- **AS46.** The gate's decision does not rest on scoring, it looks at a single flag; so on the same
  set the measure is independent of the run. The second run redraws the set with seed **20260219**.
- **AS47.** Resolution: with 93 harmful and 147 clean records, the step of recall is **0.0108** and
  the step of the false block rate is **0.0068**. The gate's precision is read from a base of 27
  records, with a step of **0.0370**.
- **AS48.** The sandbox, the principle of least privilege, and the allowlist procedure were measured
  in earlier curricula and are not repeated; this lesson adds only the count of the request.

#### Authority Is a Flag, Not a Text

The first block builds the set and the gate. Nothing in the record runs: the `side_effect_tool_name`
field is `True` or `False`, and this flag says that the record carries a wish to turn into a
side-effect tool call. Which tool the flag corresponds to is not written, because what is measured is
not the tool but **the gate**. The text field is an ordinary support request and has no relation to
the class.

```python
# FICTIONAL. Records, classes and defenses are defined inside the lesson; no real
# system is called. No attack TEXT is written: an attack is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Entirely harmless support texts; they have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]


def build_set(seed=SEED):
    ks = []
    for i in range(240):
        r = rng(seed + 6151 * i)
        c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
        source = SOURCES[pick(r(), SOURCE_SHARE[c])]
        feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
        ks.append({"no": i + 1, "class": c, "source": source, "features": feat,
                   "text": TEXT[int(r() * len(TEXT))],
                   "length": 40 + int(r() * 260)})
    return ks


RECORDS = build_set()


def harmful(k):
    return k["class"] != "clean"


def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


def approval_gate(k, required=True):
    """A record that carries a side-effecting tool request is sent to a human."""
    return required and k["features"]["side_effect_tool_name"]


def measure(records, decision):
    """What was blocked and what was blocked by mistake are written TOGETHER."""
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### What the Gate Counts

The second block runs four setups side by side: the no-gate baseline, the gate that looks at a single
flag, and two widenings. Then it prints the share the gate sees per class, what it adds when placed on
top of the rule filter, and the band of the two runs.

```python
def gate_role(k):
    """The gate widens: a role change request is added to the side-effect tool name."""
    return k["features"]["side_effect_tool_name"] or k["features"]["role_change_request"]


def gate_source(k):
    """The gate widens further: every source other than the user goes to a human."""
    return k["features"]["side_effect_tool_name"] or k["source"] != "user"


print("records", len(RECORDS), " harmful", sum(1 for k in RECORDS if harmful(k)),
      " carrying a side-effect tool name", sum(1 for k in RECORDS if approval_gate(k)))
print("\ngate              to human  harmful  clean  precision   recall")
for name, decision in (("no-gate baseline", lambda k: False), ("side-effect name", approval_gate),
                       ("+ role mode", gate_role), ("+ source", gate_source)):
    m = measure(RECORDS, decision)
    print(f"{name:<17} {m['tp'] + m['fp']:>8} {m['tp']:>8} {m['fp']:>6}"
          f" {m['precision']:>10.4f} {m['recall']:>8.4f}")

print("\nshare of each class going to the gate")
for c in CLASSES:
    t = [k for k in RECORDS if k["class"] == c]
    sent = [k for k in t if approval_gate(k)]
    print(f"  {c:<20} {len(t):>3} {len(sent):>4}   {len(sent) / len(t):.4f}")

rule = measure(RECORDS, rule_filter)
stacked = measure(RECORDS, lambda k: rule_filter(k) or approval_gate(k))
print("\nrule filter       tp", rule["tp"], " fp", rule["fp"],
      " precision", rule["precision"], " recall", rule["recall"])
print("rule + gate       tp", stacked["tp"], " fp", stacked["fp"],
      " precision", stacked["precision"], " recall", stacked["recall"])
print("records sent to the gate that the rule did not see:",
      sum(1 for k in RECORDS if approval_gate(k) and not rule_filter(k)))

for t in (20260218, 20260219):
    ks = build_set(t)
    m = measure(ks, approval_gate)
    print(f"run {t}: harmful {sum(1 for k in ks if harmful(k))}"
          f"  to human {m['tp'] + m['fp']}  harmful sent {m['tp']}"
          f"  precision {m['precision']:.4f}")
```

```
records 240  harmful 93  carrying a side-effect tool name 27

gate              to human  harmful  clean  precision   recall
no-gate baseline         0        0      0     0.0000   0.0000
side-effect name        27       24      3     0.8889   0.2581
+ role mode             58       53      5     0.9138   0.5699
+ source               111       62     49     0.5586   0.6667

share of each class going to the gate
  clean                147    3   0.0204
  indirect_instruction  24   10   0.4167
  hidden_field_request  17    1   0.0588
  privilege_escalation  10    8   0.8000
  source_spoofing       23    3   0.1304
  corrupted_input       19    2   0.1053

rule filter       tp 74  fp 23  precision 0.7629  recall 0.7957
rule + gate       tp 74  fp 23  precision 0.7629  recall 0.7957
records sent to the gate that the rule did not see: 0
run 20260218: harmful 93  to human 27  harmful sent 24  precision 0.8889
run 20260219: harmful 83  to human 17  harmful sent 11  precision 0.6471
```

#### The Precision of the Gate

The baseline row is empty, and being empty is a finding: in the no-gate setup, the **27** requests
that carry a side-effect tool name run without ever being asked, blocked harmful is **0**, false alarm
is **0**. Everything the gate adds is written on top of this zero.

The gate sends these 27 records to a human and **24** of them are actually harmful: precision
**0.8889**. More than three of every four records going to the gate deserve review, which is a high
rate for a human queue. The recall of the same row is **0.2581**: the gate sees a quarter of the 93
harmful records. The gate cannot be discussed without writing these two numbers side by side: someone
who looks at precision alone takes the gate for a good defense, someone who looks at recall alone
takes it for useless. Together they say what the gate is: **a narrow and accurate routing**, not a
broad filter.

The class table shows where this narrowness looks. **8** of the 10 records of the privilege escalation
class go to the gate (0.8000); **1** of the 17 records of the hidden field request class goes (0.0588).
The gate does not see a request for a field, and it is right not to: asking for a hidden field is not
a side-effect action, and the defense of that class is another layer. **10** of the 24 records of the
indirect instruction class go to the gate (0.4167): an instruction from a retrieved document most often
needs a tool to run to be effective, so these two classes overlap structurally. The share in clean
records is **0.0204**, that is, 3 of the 147 clean records go to a human.

The widening rows tell two separate stories. Adding the role mode to the gate does the opposite of what
is expected: precision rises **from 0.8889 to 0.9138** and recall **from 0.2581 to 0.5699**, twenty-nine
steps of 0.0108. The human queue grows from 27 to 58, but **29** of the 31 added records are actually
harmful. This is a rare row in the course: the widened defense improved both numbers at once.

Adding the source to the gate does not do the same. The records going to a human are **111**, precision
is **0.5586**, and **49** of the clean records fall into the human queue. Compared with the gate with the
role mode, caught harmful rises from 53 to 62, nine steps; clean records going to a human rise from 5 to
49, forty-four steps. Forty-four reviews were bought for nine harmful records. At this point the gate
stops being a gate and becomes a queue, and the human looking at the queue is also a defense layer: since
all 111 records will not be looked at with the same attention, the **real** recall of the gate is below
the 0.6667 in the table.

The count in the last two rows is the quietest finding of this lesson. When the gate is placed on top of
the rule filter, none of the four numbers moves: tp 74, fp 23, precision 0.7629, recall 0.7957 — the
setup with the gate and the one without it are **exactly the same**. The reason is in the last line:
**none** of the 27 records going to the gate escapes the rule filter, because
`side_effect_tool_name` is already one of the rules. Saying that a setup has both a rule filter and an
approval gate and counting two layers is counting an addition of **zero** on the detection side as two.
The value of the gate is elsewhere and does not show in these four numbers: it turns a blocked record
into a reviewed one. The measure of that value belongs to the defense topic and is not in this table.

The count on the declaration side and the count on the request side do not measure the same thing, and
substituting one for the other is the easiest mistake on this topic. What the previous course audited
was **three definitions**, and it gave six findings; findings came to two per definition, and the same
audit produced two findings on trusted servers as well. That number is **independent of traffic**: it
does not change unless the set of definitions changes, it does not depend on how many requests arrive,
and it is read once. The number here depends entirely on traffic: 27 of 240 records carry the flag,
about one in every nine, and this share changes when the composition of the set changes. A setup that
gives zero findings on the definition side says nothing about the request side, because a tool with a
clean definition can also be called with a wrong request. The reverse is also true: the gate's precision
of 0.8889 does not close the question of who wrote the tool's definition. The two numbers belong to two
separate surfaces, and the security of a setup can be read only when both are written together.

The two runs show how much the gate's precision moves. With the same gate and the same rule, the second
draw sends 17 records to a human and 11 of them are harmful: precision **0.6471**. The difference from
the first run's 0.8889 is **0.2418**, more than six times the 0.0370 step of the base of 27 records. The
gate's precision is not a property of the gate but a property of how the draw distributed the
side-effect flag across classes. A 0.8889 read from one run is not a measurement but an observation.

#### Summary

- In the no-gate baseline, 27 requests that carry a side-effect tool name run without being asked;
  blocked harmful is 0, false alarm is 0.
- The gate sends 27 records to a human and 24 are actually harmful: precision 0.8889, recall 0.2581.
  The gate is a narrow and accurate routing, not a broad filter.
- The gate sees 8 of the 10 privilege escalation records and 1 of the 17 hidden field request
  records; 10 of the 24 indirect instruction records go to the gate.
- A gate widened by role mode improves both numbers at once; a gate widened by source buys forty-four
  clean reviews for nine harmful records and drops precision to 0.5586.
- A gate placed on top of the rule filter moves none of the four numbers; the rule already flagged all
  27 records going to the gate.
- The gate's precision comes out 0.8889 and 0.6471 in two runs; the difference is far above the
  resolution of the 27-record base.

#### Next Step

What was counted in this lesson belonged to the **request**: a record arrived, carried a flag, and the
gate routed it to a human or did not. The gate's decision and the rule filter's decision both looked at
the record itself. But the record itself is not the only thing that determines which class it falls
into: what the model counts as a legitimate request, how much weight it gives to text from which
source, and which pattern it finds familiar come from **the data it was fed**. The sources are not all
the same, and no lesson so far has asked this: how different from each other is the harmful share
carried by records from the user, from a retrieved document, and from a tool output, and what does
closing one source gain and lose? The next lesson counts this.

### Training Data Poisoning

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/training-data-poisoning

Poisoning counted as the harmful share per source class, not as a procedure: 93 of 240 records in the unfiltered corpus are harmful, a share of 0.3875. The share is 0.2701 for the user, 0.5417 for the retrieved document, 0.5484 for the tool output, yet volume reverses the order: the lowest-share channel adds 37 harmful records and the highest adds 17. The source gate lowers the corpus share to 0.2701 and loses 47 clean records; the rule gate lowers it to 0.1329 and loses 23. Using both gives 0.1383, a difference below the 0.0106 step of a 94-record corpus, so unmeasurable, while lost clean records rise from 23 to 66. Rule filter recall is 0.6486 on the user channel and 0.9487 on retrieved documents. Shares move across two runs; the ordering does not.

What the previous lesson counted belonged to the **request**: a record arrived, carried the side-effect
tool name flag, and the approval gate routed it to a human or did not. The decision rested on a single
record, and once processed the record left no trace in the system. A system fed from its own traffic
does not work this way. Answered support requests, retrieved documents, and tool outputs are collected
in a **corpus**, that corpus becomes the input of the next adaptation, and from that point the harmful
attribute profile stops being an event and becomes a **feature**.

This is exactly what training data poisoning is, and this lesson treats it not as a procedure but as
**a class and a count**. The question is: what share of the records entering the corpus carries a
harmful attribute profile, how does that share change by source class, and what does keeping a source
out of the corpus gain and lose? No poisoning procedure, sample generation, or label corruption method
is written.

The distinction decides the unit of measurement. When a defense at run time decides wrongly, a single
record is affected and the decision can be reversed; a record that enters the corpus by mistake spreads
to every response fed by that corpus and is not repaired by undoing one record. So on the corpus side
the measured unit is not the record but the **share**.

- **AS49.** The record set, the source classes, and the gates are **fictional**; there is no real
  corpus or training run. Seed **20260218**.
- **AS50.** An attack is not a text but an **attribute profile**. The text of the records is entirely
  harmless support requests and has **no relation to the class**; the class lives only in the
  attribute profile.
- **AS51.** Poisoning here is **the harmful share of the corpus**. What a record does to the model
  after entering the corpus is neither measured nor claimed in this lesson; the only thing measured is
  the composition of the corpus.
- **AS52.** The baseline is the **unfiltered corpus**: no gate, all 240 records are taken, stopped
  harmful is **0**, lost clean is **0**.
- **AS53.** The source stamp is read from the record itself and assumed **correct**. Spoofing the stamp
  is a separate class (`source_spoofing`) and this lesson counts that class's stamp as correct too;
  so the count for filtering by source here is the **most optimistic** count.
- **AS54.** The source gate is a **corpus gate**, not a block at run time. Its cost is not blocked
  traffic but the number of records not taken into the corpus.
- **AS55.** Resolution: the step of the share is **0.0042** at 240 records, **0.0070** if the remaining
  corpus is 143 records, and **0.0106** at 94 records. The step per source is **0.0073** for the user,
  **0.0139** for the retrieved document, and **0.0323** for the tool output.
- **AS56.** Data quality, data leakage, and the training data preparation procedure were measured in
  earlier curricula and are not repeated; this lesson adds only the share per source.

#### Poisoning Is a Share, Not a Procedure

The first block builds the set, the classes, and the rule filter. The source of a record can be `user`,
`retrieved_document`, or `tool_output`, and every class has a different source distribution: the
indirect injection class comes mostly from retrieved documents, the corrupted input class mostly from
the user. This distribution is fictional, and the poisoning count comes from it.

```python
# FICTIONAL. Records, source classes and gates are defined inside the lesson; there is no
# real corpus or training run. No attack TEXT is written: it is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Entirely harmless support texts; they have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]


def build_set(seed=SEED):
    ks = []
    for i in range(240):
        r = rng(seed + 6151 * i)
        c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
        source = SOURCES[pick(r(), SOURCE_SHARE[c])]
        feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
        ks.append({"no": i + 1, "class": c, "source": source, "features": feat,
                   "text": TEXT[int(r() * len(TEXT))],
                   "length": 40 + int(r() * 260)})
    return ks


RECORDS = build_set()


def harmful(k):
    return k["class"] != "clean"


def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


def measure(records, decision):
    """What was stopped and what was stopped by mistake are written TOGETHER."""
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### Counting per Source

The second block first puts the harmful share per source, then four corpus gates side by side. A record
caught by a gate is not blocked, only left out of the corpus; so the cost column is **lost clean
records**.

```python
def assemble(records, gate):
    """Corpus gate: a record caught by the gate is NOT taken into the corpus."""
    kept = [k for k in records if not gate(k)]
    dropped = [k for k in records if gate(k)]
    h = sum(1 for k in kept if harmful(k))
    return (len(kept), h, h / len(kept) if kept else 0.0,
            len(dropped), sum(1 for k in dropped if not harmful(k)))


def non_user(k):
    return k["source"] != "user"


print("source              records  harmful  harmful share")
for src in SOURCES:
    group = [k for k in RECORDS if k["source"] == src]
    h = sum(1 for k in group if harmful(k))
    print(f"{src:<19} {len(group):>7} {h:>8} {h / len(group):>14.4f}")
total = sum(1 for k in RECORDS if harmful(k))
print(f"{'whole set':<19} {len(RECORDS):>7} {total:>8} {total / len(RECORDS):>14.4f}")

print("\ncorpus gate       kept  harmful  harmful share  dropped  dropped clean")
for name, gate in (("no filter", lambda k: False), ("source gate", non_user),
                   ("rule gate", rule_filter),
                   ("source + rule", lambda k: non_user(k) or rule_filter(k))):
    n, h, share, nd, dc = assemble(RECORDS, gate)
    print(f"{name:<16} {n:>5} {h:>8} {share:>14.4f} {nd:>8} {dc:>14}")

print("\nrule filter per source")
for src in SOURCES:
    s = measure([k for k in RECORDS if k["source"] == src], rule_filter)
    print(f"  {src:<19} tp {s['tp']:>3}  fp {s['fp']:>3}"
          f"  precision {s['precision']:.4f}  recall {s['recall']:.4f}")

print("\nclasses of the 37 harmful records of the user channel")
uh = [k for k in RECORDS if k["source"] == "user" and harmful(k)]
print("  " + "  ".join(f"{c} {sum(1 for k in uh if k['class'] == c)}"
                       for c in CLASSES[1:] if any(k["class"] == c for k in uh)))

print("\nharmful share per source, two runs")
for t in (20260218, 20260219):
    ks = build_set(t)
    row = []
    for src in SOURCES:
        group = [k for k in ks if k["source"] == src]
        row.append(f"{src} {sum(1 for k in group if harmful(k)) / len(group):.4f}")
    print(f"  {t}: " + "  ".join(row))
```

```
source              records  harmful  harmful share
user                    137       37         0.2701
retrieved_document       72       39         0.5417
tool_output              31       17         0.5484
whole set               240       93         0.3875

corpus gate       kept  harmful  harmful share  dropped  dropped clean
no filter          240       93         0.3875        0              0
source gate        137       37         0.2701      103             47
rule gate          143       19         0.1329       97             23
source + rule       94       13         0.1383      146             66

rule filter per source
  user                tp  24  fp  19  precision 0.5581  recall 0.6486
  retrieved_document  tp  37  fp   4  precision 0.9024  recall 0.9487
  tool_output         tp  13  fp   0  precision 1.0000  recall 0.7647

classes of the 37 harmful records of the user channel
  indirect_instruction 2  hidden_field_request 13  privilege_escalation 3  source_spoofing 3  corrupted_input 16

harmful share per source, two runs
  20260218: user 0.2701  retrieved_document 0.5417  tool_output 0.5484
  20260219: user 0.2323  retrieved_document 0.5484  tool_output 0.5652
```

#### The Rate and the Volume of a Channel

The first table builds the case for filtering by source: the harmful share is **0.5417** for the
retrieved document, **0.5484** for the tool output, and **0.2701** for the user. The two non-user
channels carry a rate close to twice that of the user channel. The difference between the user and the
retrieved document is **0.2716**, more than nineteen times the 0.0139 step of the retrieved document
channel; this is not a fluctuation but a measurement. Anyone reading up to here reaches a clear
conclusion: the corpus should take only records coming from the user.

The second column of the same table breaks that conclusion. The user channel adds **37** harmful
records, the retrieved document **39**, and the tool output **17**. The lowest-rate channel brings only
two fewer harmful records than the retrieved document channel, because **137** of the records come from
there. **The rate is a property of a channel, the volume is its weight**; when the two are not written
separately, the sentence "what comes from the user is cleaner" is both true and misleading at once.
Any decision that leaves the user channel open keeps about two fifths of the harmful records of the
corpus in place.

The second table puts two gates side by side. The source gate lowers the corpus share from 0.3875 to
**0.2701**, drops 103 records, and **47 of them are clean**. The rule gate lowers the same share to
**0.1329**, drops 97 records, and **23 of them are clean**. The rule gate is superior on both axes: it
leaves less harmful in the corpus and loses fewer clean records. The reason is structural. The source
is a **proxy** for the class, and a weak one; the attribute profile stands much closer to the class
itself. The farther a proxy criterion is from what it wants to measure, the more clean records it burns.

The difference between the third and the fourth row is the place in this lesson that needs the most
careful reading. Placing the source gate on top of the rule gate moves the share from 0.1329 to
**0.1383**, that is, it does not improve it. But writing this difference as a finding would be wrong:
in a 94-record corpus the step of the share is **0.0106** and the difference is **0.0054**, below the
step. The two shares are **indistinguishable**; the gain of the layer is unmeasurable. What can be
measured is the cost: lost clean records rise from 23 to **66**, forty-three more, and the corpus shrinks
from 143 to 94. The second claim of the course appears here in its cleanest form — **the added defense
charged a measurable cost for an unmeasurable gain**. Saying that a setup has two gates and counting
two layers means, in this table, one layer and forty-three losses.

The third table shows why the rule filter behaves differently across channels. Recall is **0.9487** on
the retrieved document, 0.7647 on the tool output, and only **0.6486** on the user. The filter is
weakest in the channel from which most records come. The reason is written in the fourth table:
**16** of the 37 harmful records of the user channel fall in the corrupted input class, and the two
flags that class carries, `unusual_stamp_distribution` and `excessive_length`, are **not** in the rule
list. The weakness is a property not of the channel but of **the list**, and it cannot be closed by
closing a channel. The precision of 1.0000 in the same table also needs careful reading: there are only
14 clean records in the tool output channel, the step of the false block rate there is 0.0714, so that
perfect number is a number read from fourteen records.

The same table also shows the weakest place of a source-based defense. The source spoofing class is, by
its name, the class where the source stamp does not agree with the rest of the record; **3** of the 23
records of that class carry a user stamp and pass the source gate clean. Because this lesson counts the
stamp as correct, the 0.2701 of the source gate is its **most optimistic** number. In a setup where the
stamp is decided by the party sending the record, the gate reads its decision from a field written by
the very party it wants to filter. The rule filter carries no such dependency: the flags it looks at
come from the structure of the record, not from its stamp. Part of the difference between 0.2701 and
0.1329 across the two gates is the cost of this dependency, and when the stamp stops being trustworthy
the difference does not close, it widens.

The last lines say which part of the measurement is independent of the run. In the second draw the
shares move: the user from 0.2701 to 0.2323, the retrieved document from 0.5417 to 0.5484, the tool
output from 0.5484 to 0.5652. None of the values is fixed. What is fixed is the **ordering**: the user
is lowest and the tool output highest in both runs. A source-based priority can safely be built on the
ordering; a threshold built on a particular share moves out of place in the second run.

#### Summary

- In the unfiltered corpus 93 of 240 records are harmful, a share of 0.3875; lost clean records in the
  baseline are 0.
- The harmful share per source is 0.2701 for the user, 0.5417 for the retrieved document, 0.5484 for the
  tool output; but volume reverses the order, and the lowest-share channel adds 37 harmful records.
- The source gate lowers the share to 0.2701 and loses 47 clean records; the rule gate lowers it to
  0.1329 and loses 23 clean records, so it is superior on both axes. The source is a weak proxy for the
  class.
- Placing the source gate on top of the rule gate gives a share of 0.1383; the difference of 0.0054 is
  below the 0.0106 step of the 94-record corpus and is unmeasurable. Lost clean records rise from 23 to
  66.
- Rule filter recall is 0.6486 on the user channel and 0.9487 on the retrieved document; the weakness is
  a property of the rule list, not of the channel.
- The shares move across two runs; the ordering of the channels does not.

#### Next Step

Everything counted in these two lessons was **on the input side**: the flag the record carried, the
channel it came from, the gate sending it to a human or not taking it into the corpus. Both the rule
filter and the source gate decided before anything reached the model. But the information a system leaks
does not have to pass through the input. A model can put a field in its response without ever being
asked, can reveal that a subscriber is in the set through an expression that carries no field directly
about that subscriber, and none of this produces a flag on the input side. The next lesson moves the
measure to the **output side**: how many responses carry a field of a secret field class, how many the
output filter removes, and what is left after the filter.

### Model Inversion and Inference Attacks

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/model-inversion-and-inference-attacks

Leakage counted on the output side, not the input side: 71 of 240 responses carry a field of a secret field class, 106 fields in total. 31 of them come from the 32 records that asked for a field, and 40 come from the 208 records that never asked, so the model inversion class is larger than what was requested. The four-name output filter removes all 106 fields and the remaining secret field count is 0; yet 47 responses still carry an indirect identifier outside the secret field class, 57 responses still carry a membership signal that has no field name, and responses carrying a trace fall only from 137 to 94. A five-name list also removes the indirect identifier but cannot remove the membership signal. 58 of the 106 removed fields come from clean records, and 41 clean responses lose a field. Across two runs the field count is 106 and 104, and responses carrying a trace after the filter are 94 in both.

Everything counted in the two previous lessons was **on the input side**: the flag the record carried,
the channel it came from, the approval gate sending it to a human, and whether it was taken into the
corpus. Both the rule filter and the source gate made their decision before the model produced a
response. The information a system leaks, however, does not have to pass through the input. The model
can put an unasked field in its response; it can reveal that a subscriber is among the records through
an expression that carries no field name; and neither produces a flag on the input side.

This lesson treats two classes. **Model inversion** is recovering a field of the training data through
the response. **Membership inference** is reading from the response whether a particular record is in
the set. Both are treated here as **a class and a count**; no inference procedure, query sequence, or
recovery method is written. The personal data and masking procedure was measured in the Model
Evaluation, Interpretability and Ethics course and is not repeated; the only thing added here is **the
count on the output side**.

- **AS57.** The records, the responses, the secret field class, and the output filter are
  **fictional**; no real model is called and no real personal data is used. Seed **20260218**.
- **AS58.** An attack is not a text but an **attribute profile**. The text of the records is entirely
  harmless support requests and has **no relation to the class**.
- **AS59.** The **text** of a response is not produced; the only things produced and counted are the
  **field names the response carries**. A field being present in a response does not mean the value of
  that field is correct, and this lesson makes no such claim.
- **AS60.** The baseline is the **unfiltered output**: no field is removed, removed fields are **0**,
  fields deleted from clean responses are **0**.
- **AS61.** `meter_history` is deliberately kept **outside** the secret field class. It is an indirect
  identifier: alone it carries no identity but it narrows down the record. A filter based on a name list
  sees it only when it is written into the list.
- **AS62.** The membership signal carries **no field name**. It is a yes-or-no that shows the record is
  in the set, and for this reason a name list cannot remove it.
- **AS63.** Resolution: in 240 responses the step of a response-level share is **0.0042**. Field counts
  are integers and no ratio is printed. The second run takes the same records with seed **20260219**.
- **AS64.** The cost on the task of masking on the input side belongs to the defense topic and is not
  measured here; the cost in this lesson is only **the fields deleted from the output**.

#### Leakage Is Counted in the Output

The first block builds the set and adds the output side on top. A response is not a text but a **list of
fields**: which names are present in the response, plus a signal that shows the record is in the set.
A field entering the response does not depend on its having been requested — there is a low share in
unrequested records as well, and the model inversion class stands exactly there.

```python
# FICTIONAL. Records, classes and defenses are defined inside the lesson; no real
# system is called. No attack TEXT is written: an attack is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Entirely harmless support texts; they have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]


def build_set(seed=SEED):
    ks = []
    for i in range(240):
        r = rng(seed + 6151 * i)
        c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
        source = SOURCES[pick(r(), SOURCE_SHARE[c])]
        feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
        ks.append({"no": i + 1, "class": c, "source": source, "features": feat,
                   "text": TEXT[int(r() * len(TEXT))],
                   "length": 40 + int(r() * 260)})
    return ks


RECORDS = build_set()


def harmful(k):
    return k["class"] != "clean"


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- OUTPUT SIDE. The response TEXT is not produced; the FIELDS IT CARRIES are counted.
SECRET_FIELD = {"subscriber_id": "identity", "contact_info": "contact", "address": "location",
                "access_string": "access"}
INDIRECT = "meter_history"       # NOT in the secret field class: an indirect identifier


def respond(k, seed=SEED):
    """FICTIONAL response. A field may appear whether or not it was requested; the membership
    signal carries no field name, it only shows that the record is in the set."""
    r = rng(seed ^ trace_id("yanit" + str(k["no"])))
    requested = k["features"]["hidden_field_name"]
    fields = [a for a in SECRET_FIELD if r() < (0.46 if requested else 0.06)]
    if r() < (0.30 if requested else 0.18):
        fields.append(INDIRECT)
    return {"no": k["no"], "requested": requested, "fields": fields, "membership": r() < 0.22}


def output_filter(resp, names):
    """Defense: a field whose name appears in the list is removed from the response."""
    return dict(resp, fields=[a for a in resp["fields"] if a not in names])


def secret_count(resp):
    return sum(1 for a in resp["fields"] if a in SECRET_FIELD)
```

#### What the Output Filter Counts

The second block first counts what the responses carry, then runs the output filter with two name lists.
The last column is the critical one: the number of **responses carrying any trace** after the filter,
that is, responses whose field or membership signal remains.

```python
RESPONSES = [respond(k) for k in RECORDS]
asked = [r for r in RESPONSES if r["requested"]]
print("responses", len(RESPONSES),
      " carrying a field of a secret field class", sum(1 for r in RESPONSES if secret_count(r)),
      " total fields", sum(secret_count(r) for r in RESPONSES))
print("  requested", sum(1 for r in asked if secret_count(r)), "/", len(asked),
      "  unasked", sum(1 for r in RESPONSES if not r["requested"] and secret_count(r)),
      "/", len(RESPONSES) - len(asked))
print("  indirect identifier", sum(1 for r in RESPONSES if INDIRECT in r["fields"]),
      " membership signal", sum(1 for r in RESPONSES if r["membership"]))

print("\noutput filter          secret field carrying fields membership responses with trace")
for name, names in (("unfiltered baseline", ()), ("narrow list (4 names)", tuple(SECRET_FIELD)),
                    ("wide list (5)", tuple(SECRET_FIELD) + (INDIRECT,))):
    F = [output_filter(r, names) for r in RESPONSES]
    print(f"{name:<22} {sum(secret_count(r) for r in F):>12} {sum(1 for r in F if r['fields']):>15}"
          f" {sum(1 for r in F if r['membership']):>10}"
          f" {sum(1 for r in F if r['fields'] or r['membership']):>20}")

clean = {k["no"] for k in RECORDS if not harmful(k)}
print("\nof the 106 fields the narrow list removed, from clean records",
      sum(secret_count(r) for r in RESPONSES if r["no"] in clean),
      " clean responses losing a field", sum(1 for r in RESPONSES if r["no"] in clean and secret_count(r)))

print("\nsame records, two runs")
for t in (20260218, 20260219):
    R = [respond(k, t) for k in RECORDS]
    F = [output_filter(r, tuple(SECRET_FIELD)) for r in R]
    print(f"  {t}: carrying fields {sum(1 for r in R if secret_count(r))}"
          f"  fields {sum(secret_count(r) for r in R)}"
          f"  indirect {sum(1 for r in R if INDIRECT in r['fields'])}"
          f"  membership {sum(1 for r in R if r['membership'])}"
          f"  carrying a trace after the filter {sum(1 for r in F if r['fields'] or r['membership'])}")
```

```
responses 240  carrying a field of a secret field class 71  total fields 106
  requested 31 / 32   unasked 40 / 208
  indirect identifier 47  membership signal 57

output filter          secret field carrying fields membership responses with trace
unfiltered baseline             106             102         57                  137
narrow list (4 names)             0              47         57                   94
wide list (5)                     0               0         57                   57

of the 106 fields the narrow list removed, from clean records 58  clean responses losing a field 41

same records, two runs
  20260218: carrying fields 71  fields 106  indirect 47  membership 57  carrying a trace after the filter 94
  20260219: carrying fields 70  fields 104  indirect 55  membership 54  carrying a trace after the filter 94
```

#### Recall as Long as the List

The first line gives the baseline: **71** of 240 responses carry at least one field of the secret field
class, and **106** fields in total come out. In the unfiltered setup none of these is removed, and the
fields deleted from clean responses are 0. By the rule of the course both numbers are zero at the
baseline, and what the defense adds is written on top of this zero.

The second line carries the class distinction of this lesson. Of the **32** records with a hidden field
name, 31 have a response that carries a field as expected. But **40** of the responses in which fields
appear come from the 208 records that never asked for a field. So the fields appearing unasked are
**more** than those appearing on request. This is the count of the model inversion class, and no defense
on the input side can see it: there is no hidden field name in the request, no instruction mode, no
flag. This class is counted only on the output side, because it exists only there.

The third table shows what the filter based on a name list does. The four-name list removes **all** of
the 106 fields: the remaining secret field is **0**. A setup that looks at this column says "leakage is
zero" about itself, and what it says is true. The three columns beside it say something else. Responses
carrying a field fall from 102 to **47**, not to zero, because the remaining 47 responses carry
`meter_history` and that name is not in the list. Responses carrying a membership signal stay at **57**,
not moving at all. And the last column gives the total: responses carrying a trace fall from 137 to
**94**. The filter removed the trace completely from only **43** of the 240 responses, yet the secret
field column writes **one hundred percent**. The recall of a name list is as long as the list, and
read from its own list that recall is always complete.

This line has a structural side, and if it is missed it is read as a measurement. The filter takes the
names from a list, and the secret field column counts from the same list; that the column comes out zero
after the filter has run is not a finding but **true by definition**. For a zero to say anything, the
counting side must be **wider** than the filtering side. The columns of 47 and 57 in the table exist for
exactly this reason: the indirect identifier and the membership signal are not in the filter's list but
are in the scope of the count, and the residue becomes visible only in the gap between these two scopes.
When the list of the filter and the definition of the measure are the same, a setup cannot see its own
limit, and the only thing it sees is its own list.

Adding a fifth name to the list corrects part of this: responses carrying a field fall to **0**, and
responses carrying a trace fall from 94 to **57**. The remaining 57 are the membership signal, and they
do not come out however long the list grows, because there is no name to remove. This is the relation of
the membership inference class to masking: masking deletes a **field**, it cannot delete a **presence**.
A response carrying the difference between "no record found for this subscriber" and "a record was found
for this subscriber" makes the composition of the set open without containing any secret field. The
defense of this class is not a name list and does not show in this table.

The cost line writes the second half of the rule. **58** of the 106 removed fields come from the
responses of clean records, and **41 clean responses** lose a field. None of these records falls in an
attack class: a subscriber asking for their address cannot receive it, a record that wants to verify
contact information comes back empty. The filter does not look at where the removed field came from,
because if it could it would already be a classifier. **When the 106 removed fields and the 58 deleted
clean fields are not written together, this defense counts as unmeasured**, and the sentence "leakage is
zero" alone is half of this measurement.

The last two lines give the band of measuring an uncertain component. The same records, second run: the
responses carrying a field move from 71 to 70, total fields from 106 to 104, indirect identifier from 47
to 55, membership signal from 57 to 54. None of them is fixed. The number of responses carrying a trace
after the filter, on the other hand, is **94** in both runs. The only number that comes out the same in
two runs is the size of the surface the filter **cannot close**: the components move, but the total
stays in place, because the number of paths left outside the list does not depend on the list itself.

#### Summary

- In the unfiltered baseline 71 of 240 responses carry a field of the secret field class and 106 fields
  in total come out; removed fields are 0, fields deleted from clean responses are 0.
- 31 of the responses in which fields appear come from the 32 records that asked for the field, and 40
  from the 208 records that never asked; the model inversion class produces no flag on the input side.
- The four-name output filter removes all 106 fields and the secret field column writes 0; yet
  responses carrying a trace fall only from 137 to 94. The recall of a name list is as long as the
  list.
- The fifth name also removes the indirect identifier and responses carrying a trace fall to 57; the
  remaining 57 are membership signals, and since they carry no field name they cannot be removed with a
  name list.
- 58 of the 106 removed fields come from the responses of clean records and 41 clean responses lose a
  field; without writing this number the filter counts as unmeasured.
- The field counts move across two runs; responses carrying a trace after the filter come out 94 in
  both runs.

#### Next Step

In six lessons the attack surface was divided into five classes and the share the defense catches was
counted separately in each: records carrying the instruction mode, the hidden field request, the
side-effect tool name, records whose source stamp does not match, and fields on the output side. All of
them had a common assumption, and that assumption was never written: that the input is **a meaningful
text**. The rule filter looks for an instruction mode, the output filter looks for a field name, the
source gate reads a stamp — all three expect to find a readable structure in front of them. The
deliberate **corruption** of the input, that is, moving the structure itself, is a separate surface that
does not meet this expectation, and it already stands in the set as its own class. The next lesson looks
at that class and compares the share the defense catches there with the shares of the other classes.

### Adversarial Examples

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/adversarial-examples

Deliberate corruption of the input counted as a separate surface: the same rule filter gives recall between 1.0000 and 0.8696 in four classes but drops to 0.2632 in the corrupted input class, and four of the five records it catches there are caught because they carry a feature of another class. Adding the unusual token distribution flag to the rule raises class recall to 0.8421 and set recall from 0.7957 to 0.9140; the same step lowers precision from 0.7629 to 0.7391 and moves the false block rate from 0.1565 to 0.2041. The model-based filter stays at 0.4737 in the same class and moves between 0.4737 and 0.4211 across two runs; since this equals the class resolution of 0.0526, it is not counted as a finding.

Up to here five classes have been counted: the indirect injection coming from retrieved content, the
hidden field request, privilege escalation, source spoofing, and the field leaking on the output side.
All five silently assumed the same thing: the input **says** something. The instruction mode is the mood
of a sentence, a hidden field name is a name, a source stamp is a claim. The defense was built on this
assumption too — the rule filter reads structural markers tied to the **meaning** of the text.

An input can be a surface without saying anything. A deliberately corrupted input targets the way the
defense reads: it departs from the order the reading side is used to, and this departure has nothing to
do with meaning. This lesson adds that class by name and asks a single question: **how much of it does
the same defense catch.**

- **AS81.** The record set, the classes, the attributes, and the defense are **fictional**; no real
  model or filter is called. Seed **20260218**, second run **20260219**.
- **AS82.** **An attack is not a text but an attribute profile.** The text of the records is entirely
  harmless support requests and has no relation to the class; the class lives only in the attribute
  profile.
- **AS83.** **No corruption procedure is written.** The corrupted input class is represented by two
  flags: `unusual_stamp_distribution` and `excessive_length`. A flag is a marker, not content.
- **AS84.** All of the code that runs is on the defense side: classification, filter, count, and measure.
- **AS85.** The adversarial example itself was treated in the Deep Learning course; **the architecture
  and the robustness procedure are not repeated**. The only thing measured here is the share the defense
  catches in this class.
- **AS86.** The undefended baseline is the same in every lesson: no filter, blocked harmful is **0**,
  false alarm is **0**.
- **AS87.** The resolution of the set is the lower limit of the measurement band: the step of recall is
  **1/93 = 0.0108**, the step of the false block rate is **1/147 = 0.0068**. Within a class the step is
  larger and is written separately.

#### The Meaningful Text Assumption

The first block builds the common definition of the course. The record set is 240 fictional support
requests; every record has a class, a source, and seven structural attributes. None of these attributes
is a working payload — each is a flag, and the text a record carries is one of six harmless support
sentences, independent of its class. The profile of the corrupted input class is structurally separate
from the others: its `instruction_mode` share is 0.05 and its `hidden_field_name` share is 0.04 — that
is, almost none of the markers the rule filter reads is present. In contrast, its
`unusual_stamp_distribution` share is 0.80 and its `excessive_length` share is 0.62.

```python
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Per-class feature probability (fictional). Clean records also carry low shares:
# in the real world, well-intentioned text can carry these features too.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### Recall per Class

At the set level the recall of the rule filter is 0.7957, and this single number is an average. Under the
average stand five separate classes; the second block breaks the same filter down class by class and
writes next to each row the class's **own resolution**.

```python
HARMFUL_CLASSES = CLASSES[1:]
print("class                records     caught      recall  resolution")
for c in HARMFUL_CLASSES:
    group = [k for k in RECORDS if k["class"] == c]
    caught = sum(1 for k in group if rule_filter(k))
    print(f"{c:<20} {len(group):>7} {caught:>10} {caught / len(group):>11.4f} {1 / len(group):>11.4f}")

carriers = [k for k in RECORDS if k["features"]["unusual_stamp_distribution"]]
print("\nrecords carrying unusual_stamp_distribution:", len(carriers),
      " of them harmful:", sum(1 for k in carriers if harmful(k)),
      " of them corrupted_input:", sum(1 for k in carriers if k["class"] == "corrupted_input"))
print("records the rule filter caught in the corrupted_input class:")
for k in RECORDS:
    if k["class"] == "corrupted_input" and rule_filter(k):
        print("  record", k["no"], "-", ", ".join(o for o in FEATURES if k["features"][o]))
```

```
class                records     caught      recall  resolution
indirect_instruction      24         24      1.0000      0.0417
hidden_field_request      17         16      0.9412      0.0588
privilege_escalation      10          9      0.9000      0.1000
source_spoofing           23         20      0.8696      0.0435
corrupted_input           19          5      0.2632      0.0526

records carrying unusual_stamp_distribution: 29  of them harmful: 21  of them corrupted_input: 15
records the rule filter caught in the corrupted_input class:
  record 27 - hidden_field_name, unusual_stamp_distribution, excessive_length
  record 100 - instruction_mode, unusual_stamp_distribution, excessive_length
  record 212 - source_stamp_mismatch, unusual_stamp_distribution, excessive_length
  record 227 - side_effect_tool_name, unusual_stamp_distribution, excessive_length
  record 236 - side_effect_tool_name
```

Four classes stand in a narrow band: 1.0000, 0.9412, 0.9000, and 0.8696. The fifth is **0.2632**. The
distance is more than six times the coarsest class step and cannot be explained by the resolution of any
class — the coarsest step is the 0.1000 of the privilege escalation class, and the observed difference
is 0.6064.

What the last list says is heavier than the ratio. Four of the five records the rule filter catches in
this class are caught because they carry `hidden_field_name`, `instruction_mode`,
`source_stamp_mismatch`, and `side_effect_tool_name`; these are markers of **other classes** and happen
to be present in these records. The fifth record carries neither: it has no unusual token distribution
and no excessive length. So in this class the rule filter catches **nothing specific to the class**; the
whole of 0.2632 comes from side information. A recall measured in a class does not mean that the defense
sees that class.

The count line confirms this: there are 29 records carrying `unusual_stamp_distribution` in the set, 21
are harmful, 15 are corrupted input. The flag sits in the set and is not read.

#### Adding the Flag to the Rule

Since the flag is already in the record, the fix is seemingly a single line: it is added to the rule
list. The third block does this and, by the rule of the course, prints **two numbers together**. The same
block runs the model-based filter in two runs; in the second run the only thing that changes is the seed.

```python
WIDE = ("instruction_mode", "hidden_field_name", "side_effect_tool_name",
        "source_stamp_mismatch", "unusual_stamp_distribution")
SEED2 = 20260219


def rule_wide(k):
    return rule_filter(k, WIDE)


def model_second(k, threshold=0.40):
    """The same model filter, second run: only the seed changes."""
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED2 ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6) >= threshold


SETUPS = (("undefended baseline", lambda k: False), ("rule", rule_filter),
          ("rule + flag", rule_wide), ("model threshold 0.40", model_filter),
          ("model second run", model_second))
print("setup                   blocked   false alarm escaped precision      recall false block rate")
for name, decision in SETUPS:
    m = measure(RECORDS, decision)
    print(f"{name:<21} {m['tp']:>10} {m['fp']:>13} {m['fn']:>7} {m['precision']:>9.4f}"
          f" {m['recall']:>11.4f} {m['false_block_rate']:>17.4f}")

group = [k for k in RECORDS if k["class"] == "corrupted_input"]
print("\nrecall in the corrupted_input class (resolution 1/19 = %.4f)" % (1 / len(group)))
for name, decision in SETUPS[1:]:
    caught = sum(1 for k in group if decision(k))
    print(f"  {name:<21} {caught:>2}/{len(group)} = {caught / len(group):.4f}")
scores = sorted(score(k) for k in group)
print("score band: %.4f - %.4f  between 0.35 and 0.50: %d records"
      % (scores[0], scores[-1], sum(1 for x in scores if 0.35 <= x < 0.50)))
```

```
setup                   blocked   false alarm escaped precision      recall false block rate
undefended baseline            0             0      93    0.0000      0.0000            0.0000
rule                          74            23      19    0.7629      0.7957            0.1565
rule + flag                   85            30       8    0.7391      0.9140            0.2041
model threshold 0.40          77            20      16    0.7938      0.8280            0.1361
model second run              77            19      16    0.8021      0.8280            0.1293

recall in the corrupted_input class (resolution 1/19 = 0.0526)
  rule                   5/19 = 0.2632
  rule + flag           16/19 = 0.8421
  model threshold 0.40   9/19 = 0.4737
  model second run       8/19 = 0.4211
score band: 0.0425 - 1.0132  between 0.35 and 0.50: 9 records
```

Adding the flag raises class recall from 0.2632 to **0.8421**: caught records go from 5 to 16. At the set
level recall rises from 0.7957 to **0.9140** and escaped harmful falls from 19 to **8**. Not a single row
of the other four classes changes — the whole gain lies in one class, and this is the proof that the flag
really sees that class.

The cost stands in the same table. False alarm rises from 23 to **30**, precision falls from 0.7629 to
**0.7391**, and the false block rate moves from 0.1565 to **0.2041**. Seven extra clean records are
blocked, because this flag is present in the profile of the clean class with a share of 0.04: a
well-intentioned support request can also carry an unusual token distribution. Eleven harmful records
were gained and seven clean ones lost. **A defense that does not write these two numbers together counts
as unmeasured**; the sentence "recall 0.9140" alone hides what the setup cost. Whether the trade is worth
it is not a number the set can give: whether eleven harmful records are worth seven clean ones depends on
whom the blocked record belongs to and what happens once it is blocked. The set says only the ratio, not
the decision.

The number of the model-based filter in the same class says something else. 0.4737 is twice the rule
filter and half of the widened rule. The reason is in the score band: the weights of the two flags of
this class are 0.30 and 0.12, their total is 0.42, and the threshold is 0.40. Nine records stand between
0.35 and 0.50, within one step of the threshold. When the whole weight of a class falls slightly above the
threshold, the filter does not **separate** that class, it flips a coin.

#### Two Runs and the Resolution of the Class

In the second run only the seed changes, and set-level recall stays the same: 0.8280 in both runs. False
alarm falls from 20 to 19, so precision rises from 0.7938 to 0.8021. This difference is the size of one
record, and since the step of the false block rate is 0.0068 it stands barely above the measurable limit;
yet it is not an improvement of the defense, it is the noise of the same filter.

Within the class the situation is sharper. In the corrupted input class the model-based filter gives
0.4737 in the first run and 0.4211 in the second. The difference is **exactly one record** and the
resolution of the class is 1/19 = **0.0526**. So the observed difference is **equal to the smallest step
the set can measure in this class**, and it cannot be counted as a finding. The most honest sentence that
can be said about the number of the same defense in this class is this: it is somewhere between 0.42 and
0.47, and the set cannot separate it more finely. The rule filter and the widened rule never meet this
problem — both are noiseless, they give the same number from run to run, and this is also what makes them
**run-independent measures**.

#### Summary

- Corrupted input is a class that forms a surface even without carrying meaning; in the lesson, the
  `unusual_stamp_distribution` and `excessive_length` flags are used, not a working corruption procedure.
- The recall of the rule filter is between 1.0000 and 0.8696 in four classes and drops to 0.2632 in the
  corrupted input class; four of the five records it catches there are caught because they carry the
  marker of another class, and one carries no corruption flag.
- Adding the flag to the rule raises class recall to 0.8421 and set recall to 0.9140; the same step
  moves false alarm from 23 to 30, the false block rate from 0.1565 to 0.2041, and drops precision from
  0.7629 to 0.7391.
- The model-based filter stays at 0.4737 in this class, because the total weight of the class, 0.42,
  stands slightly above the threshold of 0.40 and nine records are squeezed between 0.35 and 0.50.
- The within-class difference between two runs is 0.4737 and 0.4211; since the class resolution is
  0.0526, this difference is not a finding. The two rule-based setups are noiseless and give a
  run-independent measure.

#### Next Step

This lesson added a class by name and showed that the defense is almost blind in it. What closed the
blind spot was not a discovery: the flag was already in the record, it was not in the rule list. A
disturbing question follows from this. This class was counted only because someone thought of looking.
How many classes were not counted because nobody looked, and what is the defense's share in them? The
next lesson treats this question not as a procedure but as a **count**: how many classes a trial
campaign covers, what share passes the defense in each class, and with what number an uncovered class
appears in the report.

### AI Red Teaming

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/ai-red-teaming

Red teaming treated not as an attack procedure but as scope and counting: a narrow campaign sweeping four classes with ten trials each reports 4 passing records in 40 trials and a passing share of 0.1000; when a fifth class is added to the same campaign the count becomes 12 passing in 50 trials and 0.2400, and the added class alone supplies 8 of them. Changing the trial selection gives 0.1800 at the same size, all harmful records give 0.2043, and the widened rule brings it down to 0.0860. A class with no row in the report has zero trials and its passing share is unmeasured; the scope of a campaign is a statement about the plan itself, not about the world. The lesson closes the topic and writes that the defense of eight lessons was a single filter.

The previous lesson added a class by name and showed that the defense was almost blind in it. That class
could have stayed uncounted for years because nobody looked for it; the only reason it turned into a
number was that someone thought of looking. This leaves an uncomfortable gap on the defense side: saying
that a system has been tested does not say what was not tested.

Red teaming is the systematic trial that tries to close this gap. In this lesson it is **not treated as
a procedure**. The procedure of penetration testing was measured in the Security Tooling and Penetration
Testing course and is not repeated; here red teaming is reduced to a single thing: **scope and
counting.** How many classes were tried, how many trials were made in each, what the share passing the
defense is, and with what number the untested appears in the report.

- **AS88.** The record set, the classes, the attributes, and the defense are **fictional**; no real model
  or filter is called. Seed **20260218**.
- **AS89.** **An attack is not a text but an attribute profile**; the text of the records is entirely
  harmless support requests and has no relation to the class.
- **AS90.** **There is no step called producing a trial.** A campaign is selecting, class by class, the
  records already standing in the set. No working attack, payload, or evasion method is written.
- **AS91.** The only number a campaign measures is **the passing share**; this is the complement of
  recall in that class.
- **AS92.** A campaign **does not measure false alarm**. It looks only at harmful records, so it produces
  only one of the course's two numbers; the second is read from the whole set.
- **AS93.** The undefended baseline is the same: blocked harmful **0**, false alarm **0**, passing share
  **1.0000**.
- **AS94.** Resolution has two layers: the step of the set is **1/93 = 0.0108**, the step of the campaign
  is **1/trials**, the step of a class is **1/trials in that class**. Every row writes its own step.

#### A Campaign Is a Scope, Not a Procedure

Three things determine a campaign: which classes were covered, how many trials were made in each class,
and against which defense it was made. All three are plan decisions, and all three move the number in the
report directly. The first block builds the common definition: 240 fictional support requests, six
classes, seven structural attributes, and the rule filter. The attributes are flags, not content; the
text of a record is independent of its class.

```python
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Per-class feature probability (fictional). Clean records also carry low shares:
# in the real world, well-intentioned text can carry these features too.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = fn = tn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### Covered Classes and the Passing Share

The second block reduces a campaign to a function and runs two plans side by side. The narrow plan is the
one written when the corrupted input class was not yet known: four classes, ten trials per class. The
wide plan adds the fifth class to the same campaign and changes nothing else.

```python
HARMFUL_CLASSES = CLASSES[1:]
WIDE = ("instruction_mode", "hidden_field_name", "side_effect_tool_name",
        "source_stamp_mismatch", "unusual_stamp_distribution")


def rule_wide(k):
    return rule_filter(k, WIDE)


def campaign(classes, per_class, decision, from_start=True):
    """Red team SCOPE count. Not producing trials but selecting records from the set;
    no attack procedure is run. The only thing measured is the passing share."""
    rows, total_n, total_p = [], 0, 0
    for c in classes:
        group = [k for k in RECORDS if k["class"] == c]
        sub = group[:per_class] if from_start else group[-per_class:]
        passed = sum(1 for k in sub if not decision(k))
        rows.append((c, len(sub), passed, passed / len(sub), 1 / len(sub)))
        total_n, total_p = total_n + len(sub), total_p + passed
    return rows, total_n, total_p


def report(name, classes, per_class, decision, from_start=True):
    rows, n, p = campaign(classes, per_class, decision, from_start)
    print(f"{name}: covered classes {len(rows)}/{len(HARMFUL_CLASSES)}  trials {n}  passed {p}"
          f"  passing share {p / n:.4f}  campaign step {1 / n:.4f}")
    for c, m, ps, share, step in rows:
        print(f"   {c:<20} trials {m:>2}  passed {ps:>2}  share {share:.4f}  step {step:.4f}")


report("narrow campaign  ", HARMFUL_CLASSES[:4], 10, rule_filter)
report("wide campaign    ", HARMFUL_CLASSES, 10, rule_filter)
```

```
narrow campaign  : covered classes 4/5  trials 40  passed 4  passing share 0.1000  campaign step 0.0250
   indirect_instruction trials 10  passed  0  share 0.0000  step 0.1000
   hidden_field_request trials 10  passed  1  share 0.1000  step 0.1000
   privilege_escalation trials 10  passed  1  share 0.1000  step 0.1000
   source_spoofing      trials 10  passed  2  share 0.2000  step 0.1000
wide campaign    : covered classes 5/5  trials 50  passed 12  passing share 0.2400  campaign step 0.0200
   indirect_instruction trials 10  passed  0  share 0.0000  step 0.1000
   hidden_field_request trials 10  passed  1  share 0.1000  step 0.1000
   privilege_escalation trials 10  passed  1  share 0.1000  step 0.1000
   source_spoofing      trials 10  passed  2  share 0.2000  step 0.1000
   corrupted_input      trials 10  passed  8  share 0.8000  step 0.1000
```

The report of the narrow campaign is a good report. Forty trials were made, four passed, the passing share
is **0.1000**. None of the four rows raises an alarm: the worst class is 0.2000, the best is 0.0000. What
this table tells its reader is plain — the defense stopped nine of every ten trials.

The wide campaign carries the same defense, the same trial density, and the same four rows. The only
difference is one more row. The passing share goes up to **0.2400**, more than double, and **eight** of
the twelve passing records come from the new row. The four rows stayed identical letter for letter; the
only thing that changed is what was looked at. The number of a campaign does not measure the strength of
the defense, it measures **the scope of the plan**.

#### The Count of the Untested Class

The third block moves the scope in three more directions: another trial selection of the same size, all
harmful records, and the widened rule. At the end it prints the two numbers of the set and the class that
has no row in the report of the narrow campaign.

```python
report("same campaign, picked from the end", HARMFUL_CLASSES, 10, rule_filter, from_start=False)
report("all harmful records              ", HARMFUL_CLASSES, 999, rule_filter)
report("all records, wide rule           ", HARMFUL_CLASSES, 999, rule_wide)
print("\nthe two numbers of the defense (whole set):")
for name, decision in (("undefended baseline", lambda k: False), ("rule", rule_filter),
                       ("rule + flag", rule_wide)):
    m = measure(RECORDS, decision)
    print(f"  {name:<19} blocked {m['tp']:>2}  false alarm {m['fp']:>2}"
          f"  recall {m['recall']:.4f}  false block rate {m['false_block_rate']:.4f}")
print("\nharmful classes defined in the set:", len(HARMFUL_CLASSES),
      " with no row in the narrow campaign's report:", HARMFUL_CLASSES[4:],
      " trials of that row:", 0)
```

```
same campaign, picked from the end: covered classes 5/5  trials 50  passed 9  passing share 0.1800  campaign step 0.0200
   indirect_instruction trials 10  passed  0  share 0.0000  step 0.1000
   hidden_field_request trials 10  passed  0  share 0.0000  step 0.1000
   privilege_escalation trials 10  passed  1  share 0.1000  step 0.1000
   source_spoofing      trials 10  passed  1  share 0.1000  step 0.1000
   corrupted_input      trials 10  passed  7  share 0.7000  step 0.1000
all harmful records              : covered classes 5/5  trials 93  passed 19  passing share 0.2043  campaign step 0.0108
   indirect_instruction trials 24  passed  0  share 0.0000  step 0.0417
   hidden_field_request trials 17  passed  1  share 0.0588  step 0.0588
   privilege_escalation trials 10  passed  1  share 0.1000  step 0.1000
   source_spoofing      trials 23  passed  3  share 0.1304  step 0.0435
   corrupted_input      trials 19  passed 14  share 0.7368  step 0.0526
all records, wide rule           : covered classes 5/5  trials 93  passed 8  passing share 0.0860  campaign step 0.0108
   indirect_instruction trials 24  passed  0  share 0.0000  step 0.0417
   hidden_field_request trials 17  passed  1  share 0.0588  step 0.0588
   privilege_escalation trials 10  passed  1  share 0.1000  step 0.1000
   source_spoofing      trials 23  passed  3  share 0.1304  step 0.0435
   corrupted_input      trials 19  passed  3  share 0.1579  step 0.0526

the two numbers of the defense (whole set):
  undefended baseline blocked  0  false alarm  0  recall 0.0000  false block rate 0.0000
  rule                blocked 74  false alarm 23  recall 0.7957  false block rate 0.1565
  rule + flag         blocked 85  false alarm 30  recall 0.9140  false block rate 0.2041

harmful classes defined in the set: 5  with no row in the narrow campaign's report: ['corrupted_input']  trials of that row: 0
```

The first row gives the campaign's own band. The same defense, the same five classes, the same fifty
trials; the only difference is which records were selected. The passing share comes out **0.1800**
instead of 0.2400. This difference of three records has nothing to do with the defense, and since the
total step of the campaign is 0.0200 it cannot be dissolved into noise either — even with the scope
fixed, **the selection alone moves 0.0600.** The number of a campaign is not repeatable; a repeated
campaign gives another number.

The second row concentrates the scope: all records instead of ten per class, 93 trials. The passing share
is 0.2043. The corrupted input row goes down from 0.8000 to **0.7368**, a difference of 0.0632; since the
step of the ten-trial campaign is 0.1000, that campaign **could not have told these two numbers apart**.
Writing a number with decimals does not make it precise.

The third row runs the campaign against a change in the defense. The widened rule brings the passing
share down from 0.2043 to **0.0860**; the whole gain again lies in one row, from 0.7368 to 0.1579. The
campaign reports this as a success and **reports it incompletely**. The false alarm of the same defense
rose from 23 to 30 and this number is in no row of the campaign, because the campaign looks only at
harmful records. A red team report, by its structure, produces **only one** of the course's two numbers;
the second must be read separately from the whole set.

The last line carries the most important sentence of the lesson. The report of the narrow campaign has no
row for `corrupted_input`, and the trial count of that row is zero. Someone who sums the table sees 40
trials, 4 passing, and 0.1000; the lost class contributes zero to the totals. But that zero **is not a
measured zero** — it is the number of not having looked. As soon as the class entered the scope, it alone
supplied eight of the twelve passing records. **The count of the untested class is not zero, it is
unknown**, and a report has to be written in a way that tells the two apart: unless covered and
uncovered classes are listed separately, a gap in scope looks like a success figure.

There is a harsher version of this. The number of harmful classes defined in this set is five; if a
sixth surface exists, it appears in no row of **any** campaign run on this set. Scope is not a number but
a claim, and it says something not about the world but about **the plan itself**.

#### Summary

- Red teaming here is not a procedure but a count: covered classes, trials per class, and the share
  passing the defense.
- The narrow campaign reports a passing share of 0.1000 in 40 trials over four classes; when the fifth
  class is added, the same defense gives 0.2400 in 50 trials, and eight of the twelve passing records
  come from the new row.
- The number of a campaign carries a band from its own selection: another selection of the same size
  gives 0.1800, the difference is 0.0600 and has nothing to do with the defense.
- As the scope concentrates, resolution grows: the passing share is 0.2043 in 93 trials and the
  corrupted input row is 0.7368; the ten-trial campaign could not have told it apart from 0.8000.
- Since the campaign looks only at harmful records, it produces one of the course's two numbers; the
  widened rule brings the passing share down to 0.0860 while raising false alarm from 23 to 30, and this
  number is not in the report.
- The trial count of a class with no row in the report is zero and its passing share is unknown; unless
  covered and uncovered are written separately, a gap looks like a success.

#### Next Step

In eight lessons the surface was counted. The text entering the model was divided into classes,
distributed by its source, swept with a threshold, records carrying hidden field names were counted, the
side-effect tool request was separated, the output side was measured, corruption that carries no meaning
was added as a separate class, and in the end the scope itself turned into a number. In all of this the
defense was **always the same thing**: a single filter. Sometimes a list of four rules, sometimes a list
of five rules widened with a flag, sometimes a score with a threshold. The work it did never changed — it
either passed the record or blocked it, and it always paid the cost in the same currency: false alarm.
Yet blocking is not the only move of a defense. Deleting a field, not counting a text as an instruction,
narrowing the authority of a tool, and asking a human before an action are defenses too, and none of them
stops the record. The next topic builds these layers one by one and measures each with its own cost: what
it catches, what it blocks by mistake, and which capability it takes away.

## Defense Layers

Counting the cost of every defense layer together with the harmful it stops: stacking the rule-based filter and the model-based filter catches no new harmful record while it lowers precision and raises the false block rate; per-class precision and recall expose what a single average hides, and in at least one class the defense falls below the baseline; the records masking cleans against the tasks lost because it deletes a field the task needs; the harmful stopped against the tasks left incomplete as the permission set narrows; the number of records untrusted content marking neutralizes without blocking anything or producing a false alarm, and the harmful that remains; how many of the records sent to the human approval gate are really harmful, and how the harmful caught shrinks as the approval rate falls.

### Input and Output Filters

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/input-and-output-filters

Counting the harmful records that rule-based and model-based checks block on the same record set together with the clean records they block by mistake: when the rule-based filter is split into an input side and an output side, one blocks 55 harmful records and the other 44, and their union rises to 74; the model-based filter at a 0.50 threshold blocks fewer harmful records but blocks far fewer clean ones; stacking the two is worse than the rule-based filter alone — recall stays at 0.7957, precision drops from 0.7629 to 0.7551, the false block rate rises from 0.1565 to 0.1633, and the layer blocks one extra clean record without catching a single new harmful one.

The previous eight lessons counted the attack surface: five harmful classes, the source distribution of
each, and the share the defense caught in that class. Throughout, the defense side was **a single
filter** asked a single question — how much of this class does it block. The filter itself was never
opened.

Yet a filter has two places to stand. One part looks at what can be read **before the text enters the
model**, another at what **the response carries out**. They do not see the same record and do not
catch the same class. A second question was never asked either: what happens when a second filter is
placed on top of the first. This lesson counts both, and the answer to the second shapes the rest of
the course.

- **DF1.** The record set, the classes and the defense layers are **fictional**; no real model
  endpoint is called. The seed is **20260218**, the second run uses **20260219**.
- **DF2.** **This is not an attack lesson.** All of the code that runs sits on the defense side:
  filter, count and measure. No working attack, payload or evasion routine is written.
- **DF3.** **An attack is not a text but a feature profile.** The `text` field carried by the records
  equals one of six ordinary support requests and **has no relation to the class**; the class
  information lives only in the structural feature profile.
- **DF4.** The baseline is the **undefended setup**: no filter, harmful records blocked 0, false
  positives 0.
- **DF5.** Splitting the rule-based filter into an input side and an output side is a fiction; the
  split is made by whether a feature can be read before the text enters the model or only after the
  response is produced.
- **DF6.** The general routine of input validation was measured in the Secure Coding course and is
  not repeated here; this lesson adds only what is specific to **text going into a language model**.
- **DF7.** The resolution of the set is the lower bound of the measurement band: the step of recall is
  **0.0108**, the step of the false block rate is **0.0068**.

#### The Record Set and the Two Numbers of a Defense

The first block sets up the course's shared definition. The six sentences in the `TEXT` list are
ordinary subscriber requests carrying no instruction, payload or evasion text, and the class they land
in is **random**. What determines the class is the `PROFILE` dictionary: which structural feature each
class carries with which probability. Clean records carry low shares too, because a well-meant request
can be long or written in the instruction mode.

```python
# FICTIONAL. The record set, classes, features and defense layers are defined inside
# the lesson; no real model endpoint is called.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# in the real world, well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

The `measure` function puts the course's rule into code: for every decision, the **harmful records
blocked** (`tp`) and the **clean records blocked by mistake** (`fp`) come back side by side. The third
column, `fn`, is the **missed harmful record**, that is, the **false negative**; throughout the course
it is called the missed harmful record. A defense that reports precision alone or recall alone counts
as unmeasured in this course.

#### Where the Filter Stands

The rule-based filter of the shared definition reads four features. Two of them are known before the
record enters the model: whether the text is written in the instruction mode (`instruction_mode`) and
whether the source stamp carried by the record matches the place it came from
(`source_stamp_mismatch`). The other two become visible only while the response is produced: the
hidden field name the response carries (`hidden_field_name`) and the requested side-effecting tool call
(`side_effect_tool_name`). The first pair is the **input filter**, the second is the **output filter**.

```python
INPUT_RULES = ("instruction_mode", "source_stamp_mismatch")
OUTPUT_RULES = ("hidden_field_name", "side_effect_tool_name")


def input_filter(k):
    """Structural features readable before the text reaches the model."""
    return rule_filter(k, INPUT_RULES)


def output_filter(k):
    """Field and call names the response will carry out."""
    return rule_filter(k, OUTPUT_RULES)


def show(name, s):
    print(f"{name:<20}{s['tp']:>4}{s['fp']:>4}{s['fn']:>4}{s['precision']:>10.4f}"
          f"{s['recall']:>12.4f}{s['false_block_rate']:>12.4f}")


print("records:", len(RECORDS), " harmful:", sum(1 for k in RECORDS if harmful(k)),
      " clean:", sum(1 for k in RECORDS if not harmful(k)))
print("source:", {a: sum(1 for k in RECORDS if k["source"] == a) for a in SOURCES})
print()
print(f"{'setup':<20}{'tp':>4}{'fp':>4}{'fn':>4}{'precision':>10}"
      f"{'recall':>12}{'false block':>12}")
show("undefended baseline", measure(RECORDS, lambda k: False))
show("input filter", measure(RECORDS, input_filter))
show("output filter", measure(RECORDS, output_filter))
show("input + output", measure(RECORDS, lambda k: input_filter(k) or output_filter(k)))
show("rule filter", measure(RECORDS, rule_filter))

output_only = [k for k in RECORDS if harmful(k) and output_filter(k) and not input_filter(k)]
input_only = [k for k in RECORDS if harmful(k) and input_filter(k) and not output_filter(k)]
missed = [k for k in RECORDS if harmful(k) and not input_filter(k) and not output_filter(k)]
print()
print(f"{'class':<20}{'input only':>13}{'output only':>14}{'missed':>7}")
for s in CLASSES[1:]:
    print(f"{s:<20}" + "".join(f"{sum(1 for k in u if k['class'] == s):>{g}}"
                               for u, g in ((input_only, 13), (output_only, 14), (missed, 7))))
print(f"{'total':<20}{len(input_only):>13}{len(output_only):>14}{len(missed):>7}")
```

```
records: 240  harmful: 93  clean: 147
source: {'user': 137, 'retrieved_document': 72, 'tool_output': 31}

setup                 tp  fp  fn precision      recall false block
undefended baseline    0   0  93    0.0000      0.0000      0.0000
input filter          55  13  38    0.8088      0.5914      0.0884
output filter         44  12  49    0.7857      0.4731      0.0816
input + output        74  23  19    0.7629      0.7957      0.1565
rule filter           74  23  19    0.7629      0.7957      0.1565

class                  input only   output only missed
indirect_instruction           11             1      0
hidden_field_request            2             8      1
privilege_escalation            0             6      1
source_spoofing                15             1      3
corrupted_input                 2             3     14
total                          30            19     19
```

#### Caught and Missed

The baseline row is the same in every lesson: with no defense, harmful records blocked **0**, false
positives **0**, missed harmful records **93**. Every defense places a cost on top of this zero.

The input filter alone blocks **55 harmful records** and blocks **13 clean** records: recall 0.5914,
false block rate 0.0884. The output filter blocks **44 harmful records** and blocks **12 clean**
records: recall 0.4731. Read on their own, the two numbers make the input side look far ahead. The
second table shows that this is misleading: the **19 harmful records** blocked by the output filter
are records the input filter **never sees**, and 8 of them belong to the hidden field request class
and 6 to privilege escalation. The structure of these two classes is exactly this — the input side of
the record looks like an ordinary request, and what sets it apart is the field name the response will
carry. Symmetrically, of the 30 records the input filter catches alone, 15 are source spoofing and 11
are indirect instruction.

The union row confirms that the two sides do different jobs: adding 55 and 44 gives not 99 but **74**,
which means that on 25 records both caught the same thing. Recall rises from 0.5914 to 0.7957 — a
difference of nineteen times the resolution. The cost is in the same table: the false block rate rises
from 0.0884 to 0.1565, and the blocked clean records from 13 to 23. The four-rule filter and the
input-plus-output union give the **identical row**; the split does not distort the set.

The last column names the blindness of the defense. Of the 19 harmful records that the two filters
miss together, **14 belong to the corrupted input class**. The profile of that class does not carry
any of the four rules with high probability; its distinguishing features are the stamp distribution and
excessive length, and the rule set does not look at them. When a class is almost entirely missed, the
average recall of 0.7957 hides it.

#### Layer on Layer

The first fix that comes to mind for the missed class is a second filter. The model-based filter gives
a weighted score to all seven features, adds a bonus by source, and decides with a threshold; it also
reads the stamp distribution that the rule-based filter ignores.

```python
def score_second(k):
    """Same model, second run: only the noise seed changes."""
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(20260219 ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


print(f"{'setup':<20}{'tp':>4}{'fp':>4}{'fn':>4}{'precision':>10}"
      f"{'recall':>12}{'false block':>12}")
show("rule filter", measure(RECORDS, rule_filter))
show("model filter 0.50", measure(RECORDS, lambda k: model_filter(k, 0.50)))
show("rule OR model", measure(RECORDS, lambda k: rule_filter(k) or model_filter(k, 0.50)))

added = [k for k in RECORDS if not rule_filter(k) and model_filter(k, 0.50)]
print("\nrecords the layer added to the rule filter:", len(added),
      " class:", [k["class"] for k in added])
print("new harmful records the layer added:", sum(1 for k in added if harmful(k)))

print("\nsecond run, same records, noise seed 20260219:")
show("rule filter", measure(RECORDS, rule_filter))
show("model filter 0.50", measure(RECORDS, lambda k: score_second(k) >= 0.50))
print("records whose decision changed between runs:",
      sum(1 for k in RECORDS if model_filter(k, 0.50) != (score_second(k) >= 0.50)))
print("recall step:", round(1 / 93, 4), " false block step:", round(1 / 147, 4))
```

```
setup                 tp  fp  fn precision      recall false block
rule filter           74  23  19    0.7629      0.7957      0.1565
model filter 0.50     66   8  27    0.8919      0.7097      0.0544
rule OR model         74  24  19    0.7551      0.7957      0.1633

records the layer added to the rule filter: 1  class: ['clean']
new harmful records the layer added: 0

second run, same records, noise seed 20260219:
rule filter           74  23  19    0.7629      0.7957      0.1565
model filter 0.50     69   8  24    0.8961      0.7419      0.0544
records whose decision changed between runs: 11
recall step: 0.0108  false block step: 0.0068
```

#### Defense Breaks Things Too

The third row is the proof of this course's second claim and it has to be written by name: **stacking
the rule-based filter and the model-based filter is worse than the rule-based filter alone.**
Precision drops from 0.7629 to **0.7551**, the false block rate rises from 0.1565 to **0.1633**, and
recall **stays at 0.7957**. The number of records the layer adds to the rule-based filter is **1**,
and that record belongs to the **clean** class. The sentence is this: the layer **did not catch a
single new harmful record, and blocked one extra clean record.**

The numbers have to be read against the resolution. The change of 0.0068 in the false block rate is
exactly one step, a single clean record. The difference in recall is **zero**, and this is not an
unknown tied to the resolution but a real zero: the added set has a single element and it is not
harmful.

Why it comes out this way is written in the second row. At a 0.50 threshold the model-based filter
blocks **fewer** harmful records (66 against 74) but blocks **far fewer** clean records (8 against 23),
and its precision is 0.8919. This is not a defense worse than the rule-based filter; it is a defense
that stands at **a different balance**. Combining the two with OR does not take the good sides of the
two defenses; it takes the **union of the sets they flag**; of the 74 records flagged by the defense
with the higher precision, 73 are already inside the set flagged by the rule-based filter. The union
only adds up the false positives. Adding a layer for its high precision means not asking **on which set**
the precision was measured.

The last block separates uncertainty. The rule-based filter gives the **identical** row in both runs;
it reads structural features, produces no score, and so is independent of the run. The
model-based filter, on the other hand, moves: on the same 240 records the decision changes for 11
records, harmful records blocked rise from 66 to 69, and recall rises from 0.7097 to 0.7419. The
difference is 0.0322, three times the resolution — not within the band but a **real** movement. The
0.0042 difference in precision and the zero difference in the false block rate, by contrast, cannot be
measured. The recall that a defense reports from a single run lies, on this set, inside a band of at
least three steps.

#### Summary

- The undefended baseline is the same in every lesson: harmful records blocked 0, false positives 0,
  missed harmful records 93.
- The rule-based filter splits into an input side (55 harmful records, 13 false positives) and an
  output side (44 harmful records, 12 false positives); their union gives 74 harmful records and 23
  false positives, because they catch different classes.
- Of the 19 harmful records that the two miss together, 14 belong to the corrupted input class; the
  average recall of 0.7957 hides this blindness.
- Stacking the rule-based filter and the model-based filter lowers precision from 0.7629 to 0.7551,
  raises the false block rate from 0.1565 to 0.1633 and leaves recall unchanged: the layer blocks one
  extra clean record without catching a single new harmful one.
- The rule-based filter gives the same number in both runs; the recall of the model-based filter moves
  between 0.7097 and 0.7419, which is three resolution steps and cannot be reported from a single run.

#### Next Step

All the numbers of this lesson were read from a single decision: block or let through. A filter that
calls a record harmful does not say **which kind**, and the measure is squeezed into a single pair of
numbers, yet the last column showed that almost an entire class is missed. If a defense also produces
a class name, it becomes possible to ask which class a single average rate hides. The next lesson
turns the filter into a **content moderation** step and prints precision and recall separately for
each class.

### Content Moderation

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/content-moderation

Counting that blocking harmful content and giving it a class name are two separate jobs: a moderation step that produces a class name gives 0.9355 recall and 0.7311 precision in the binary reading, but per class the precision spreads between 0.2917 and 0.6364 and the recall of the class label between 0.3333 and 0.8235; in the indirect instruction class the moderation step puts the label on 21 records and knows 8 of them correctly, and it marks 9 entirely clean records with the same label, so when the class label is tied to an action the action fires wrongly far more often than rightly and the defense falls below the baseline in that class; the recall of the corrupted input class rises from 0.2632 to 0.8421 while the false block rate rises from 0.1565 to 0.2177.

All the numbers of the previous lesson were read from a single decision: block or let through. The
rule-based filter blocked 74 of the 93 harmful records, blocked 23 clean records, and its average
recall came out 0.7957. The last column of the same table, however, showed that 14 of the 19 missed
records came from **a single class**. The average number did not carry that concentration.

When a defense does not stop at blocking but also tells the record **which kind of harmful it is**,
two things change at once. First, the filter can gain a rule that looks at the class it was missing.
Second, and more important, the measure stops being a single pair of numbers: each class gets its own
precision and recall. This lesson builds that distinction and counts what the average hides.

- **DF8.** The record set, the classes, the features and the content moderation step are
  **fictional**; no real model endpoint is called. The seed is **20260218**, the second run uses
  **20260219**.
- **DF9.** **This is not an attack lesson.** All of the code that runs sits on the defense side:
  classification, filter and count. No working attack, payload or evasion routine is written.
- **DF10.** **An attack is not a text but a feature profile.** The `text` field of the records equals
  one of six ordinary support requests and **has no relation to the class**; the moderation step does
  not read that text either, it reads only the structural features.
- **DF11.** The baseline is the **undefended setup**: no filter or moderation step, harmful records
  blocked 0, false positives 0, class labels produced 0.
- **DF12.** The content moderation step returns a single class name; the priority order is a fiction,
  and if a record has more than one feature only the class at the front is written.
- **DF13.** The general routine of model evaluation and subgroup analysis was measured in the
  Supervised Learning and Model Evaluation, Interpretability and Ethics courses and is not repeated
  here; this lesson adds only the reading **per defense class**.
- **DF14.** The step of a per-class rate is the size of that class and is larger than the general step
  of the set: **0.1000** in the smallest class, **0.0417** in the largest harmful class. Over all
  harmful records the step of recall is **0.0108**, the step of the false block rate is **0.0068**.

#### A Defense That Produces a Class Name

The first block sets up the course's shared definition: the record set, the classes, the rule-based
filter, the model-based filter and the measure. Which class the six support sentences fall into is
random; the class information lives only in the `PROFILE` dictionary.

```python
# FICTIONAL. The record set, classes, features and defense layers are defined inside
# the lesson; no real model endpoint is called.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# in the real world, well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### Precision and Recall per Class

The content moderation step differs from the rule-based filter in two points: it returns a class name,
and it reads one more feature, the stamp distribution. The priority order puts each class's own
distinguishing feature at the front. The second block first prints the **binary reading** — which
number comes out if saying "not clean" counts as blocking — and then opens the same decision class by
class.

```python
def content_moderation(k):
    """Defense: assigns a class name to the record. No attack text is read;
    only structural features are evaluated in priority order."""
    o = k["features"]
    if o["hidden_field_name"]:
        return "hidden_field_request"
    if o["side_effect_tool_name"]:
        return "privilege_escalation"
    if o["source_stamp_mismatch"]:
        return "source_spoofing"
    if o["unusual_stamp_distribution"]:
        return "corrupted_input"
    if o["instruction_mode"] or o["role_change_request"]:
        return "indirect_instruction"
    return "clean"


binary = measure(RECORDS, lambda k: content_moderation(k) != "clean")
print("binary reading, if saying 'not clean' counts as blocking:")
print(f"  tp {binary['tp']}  fp {binary['fp']}  fn {binary['fn']}"
      f"  precision {binary['precision']}  recall {binary['recall']}"
      f"  false block rate {binary['false_block_rate']}")
print()
print(f"{'class':<20}{'n':>4}{'assigned':>9}{'correct':>8}{'wrong class':>13}"
      f"{'clean':>6}{'precision':>10}{'recall':>12}{'step':>8}")
for s in CLASSES[1:]:
    g = [k for k in RECORDS if k["class"] == s]
    a = [k for k in RECORDS if content_moderation(k) == s]
    d = sum(1 for k in a if k["class"] == s)
    ws = sum(1 for k in a if harmful(k) and k["class"] != s)
    cl = sum(1 for k in a if not harmful(k))
    print(f"{s:<20}{len(g):>4}{len(a):>9}{d:>8}{ws:>13}{cl:>6}"
          f"{d / len(a):>10.4f}{d / len(g):>12.4f}{1 / len(g):>8.4f}")
passed = [k for k in RECORDS if content_moderation(k) == "clean"]
pc = sum(1 for k in passed if not harmful(k))
print(f"{'clean':<20}{147:>4}{len(passed):>9}{pc:>8}{len(passed) - pc:>13}{'-':>6}"
      f"{pc / len(passed):>10.4f}{pc / 147:>12.4f}{1 / 147:>8.4f}")
print()
print("classes of the harmful records passed as clean:",
      {s: sum(1 for k in passed if k["class"] == s) for s in CLASSES[1:]
       if sum(1 for k in passed if k["class"] == s)})
```

```
binary reading, if saying 'not clean' counts as blocking:
  tp 87  fp 32  fn 6  precision 0.7311  recall 0.9355  false block rate 0.2177

class                  n assigned correct  wrong class clean precision      recall    step
indirect_instruction  24       21       8            4     9    0.3810      0.3333  0.0417
hidden_field_request  17       32      14            9     9    0.4375      0.8235  0.0588
privilege_escalation  10       24       7           14     3    0.2917      0.7000  0.1000
source_spoofing       23       22      14            4     4    0.6364      0.6087  0.0435
corrupted_input       19       20      12            1     7    0.6000      0.6316  0.0526
clean                147      121     115            6     -    0.9504      0.7823  0.0068

classes of the harmful records passed as clean: {'hidden_field_request': 1, 'privilege_escalation': 1, 'source_spoofing': 1, 'corrupted_input': 3}
```

#### What the Average Hides

The first rows show the best recall so far: **0.9355**. Where the undefended baseline
blocked 0 harmful records, the moderation step flags 87 of the 93 harmful records and misses only 6.
This is where a defense would be reported with a single line, and this line is **wrong** — more
precisely, what it says is true but it does not answer the question asked. What is expected of a
content moderation step is not that the record is blocked but that **it is told what it is**; blocking
was already the job of the rule-based filter.

The class table opens the same decision by name and no single rate remains. The recall of the class
label spreads between 0.3333 and 0.8235, and its precision between 0.2917 and 0.6364. Two classes
stand out.

The recall of the `hidden_field_request` class is the highest at **0.8235**, but its precision is
**0.4375**: the moderation step puts this label on 32 records and only 14 of them really belong to that
class. Because the label stands at the front of the priority order, it pulls in every record that
carries a hidden field name — 9 of them from another harmful class, 9 of them entirely clean.

`privilege_escalation` is sharper: the label is put on 24 records, 7 are correct, **14 are from another
harmful class**. This row carries a warning — the decision to block is **right** for these 14 records,
because all of them really are harmful; only the decision given to the name is wrong. The class
precision of a defense can be 0.2917 and the same defense can look good in the binary reading.
**Blocking and naming are two separate jobs and are measured with two separate numbers.**

The last row writes the cost: the moderation step counts 115 of the 147 clean records as clean and puts
a harmful class name on **the other 32**. The false block rate is 0.2177, nine resolution steps higher
than the 0.1565 of the rule-based filter. Half of the 6 missed harmful records again belong to the
corrupted input class.

#### Recall per Class, Three Defenses

The same class split applies to the two filters of the previous lesson. The third block prints four
columns: the rule-based filter, the model-based filter, the second run of the same model filter, and
the binary decision of the moderation step. What is measured here is not the class label but **whether
the record is flagged**.

```python
def score_second(k):
    """Same model, second run: only the noise seed changes."""
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(20260219 ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


print(f"{'class':<20}{'n':>4}{'rule':>10}{'model 0.50':>12}"
      f"{'run 2':>10}{'moderation':>12}{'step':>8}")
harmful_records = [k for k in RECORDS if harmful(k)]
for s in CLASSES[1:]:
    g = [k for k in RECORDS if k["class"] == s]
    rates = [sum(1 for k in g if f(k)) / len(g)
             for f in (rule_filter, lambda k: model_filter(k, 0.50),
                       lambda k: score_second(k) >= 0.50,
                       lambda k: content_moderation(k) != "clean")]
    print(f"{s:<20}{len(g):>4}{rates[0]:>10.4f}{rates[1]:>12.4f}"
          f"{rates[2]:>10.4f}{rates[3]:>12.4f}{1 / len(g):>8.4f}")
rates = [sum(1 for k in harmful_records if f(k)) / len(harmful_records)
         for f in (rule_filter, lambda k: model_filter(k, 0.50),
                   lambda k: score_second(k) >= 0.50,
                   lambda k: content_moderation(k) != "clean")]
print(f"{'average':<20}{len(harmful_records):>4}{rates[0]:>10.4f}{rates[1]:>12.4f}"
      f"{rates[2]:>10.4f}{rates[3]:>12.4f}{1 / len(harmful_records):>8.4f}")
print()
print("false block rate:",
      {"rule": measure(RECORDS, rule_filter)["false_block_rate"],
       "model 0.50": measure(RECORDS, lambda k: model_filter(k, 0.50))["false_block_rate"],
       "moderation": binary["false_block_rate"]})
```

```
class                  n      rule  model 0.50     run 2  moderation    step
indirect_instruction  24    1.0000      0.9583    0.9583      1.0000  0.0417
hidden_field_request  17    0.9412      0.7059    0.7059      0.9412  0.0588
privilege_escalation  10    0.9000      0.8000    0.8000      0.9000  0.1000
source_spoofing       23    0.8696      0.8261    0.8261      0.9565  0.0435
corrupted_input       19    0.2632      0.2105    0.3684      0.8421  0.0526
average               93    0.7957      0.7097    0.7419      0.9355  0.0108

false block rate: {'rule': 0.1565, 'model 0.50': 0.0544, 'moderation': 0.2177}
```

#### The Class Below the Baseline

The average of 0.7957 of the rule-based filter lies between 0.8696 and 1.0000 in four classes and is
**0.2632** in the fifth. The average is four good numbers carrying one bad number; this row does not
tell the reader which class is unprotected. The moderation step raises that class to 0.8421,
because it reads the stamp distribution that the rule set does not look at — but the cost is in the
same row: the false block rate rises from 0.1565 to 0.2177, and the blocked clean records from 23 to 32.

The corrupted input row has to be read together with the second run. The recall of the model-based
filter in this class moves between 0.2105 and 0.3684 across the two runs; the difference is 0.1579,
that is, **three steps** of the resolution of that class. In the other four classes the difference is
exactly zero. The weakest class is also the most unstable class, and the number reported from a single
run for that class is not a number but a band.

Now the harshest application of the course's rule. In the `indirect_instruction` class the moderation
step puts this label on **21 records**; **8 are correct**, 4 are from another harmful class, and **9
are entirely clean**. If the class label is tied to an action — a warning specific to that class, a
rewrite, a human review — the action fires correctly 8 times and **wrongly 13 times** in this set, and
in 9 of the wrong firings there is a real subscriber on the other side. On the undefended baseline this
action never fires: neither rightly nor wrongly. If the cost of a wrong firing is not smaller than the
gain of a right firing, **in this class the defense is below the baseline.** The difference between 8
correct labels and 9 labels put on clean records is a single record and could change sign in another
draw; what is readable is that the ratio is **close to one**.

The fact that the same class reads 1.0000 in the binary column completes this table. The moderation
step blocks **all** of the indirect instruction class and puts the right name on **a third** of it. The
choice of which to write, if a single rate were reported, depends on what the defense was built for — and that is why both are written.

#### Summary

- The content moderation step that produces a class name blocks 87 harmful records and blocks 32 clean
  records in the binary reading: precision 0.7311, recall 0.9355, false block rate 0.2177.
- The class label of the same moderation step is much weaker: class precision spreads between 0.2917
  and 0.6364, class recall between 0.3333 and 0.8235. Blocking and naming are two separate jobs.
- The average of 0.7957 of the rule-based filter is above 0.8696 in four classes and 0.2632 in the
  corrupted input class; the moderation step raises that class to 0.8421, and the false block rate
  rises from 0.1565 to 0.2177.
- The corrupted input class moves between 0.2105 and 0.3684 across two runs, that is, three steps of
  the class resolution; in the other four classes the difference is zero.
- In the indirect instruction class the label is put on 21 records, 8 are correct and 9 land on
  entirely clean records; if the class label is tied to an action, the action fires wrongly far more
  often than rightly and the defense falls below the baseline in that class.

#### Next Step

In two lessons the defense decided on the **whole** of the record: let through, block, or put a class
name. In all three the record was an indivisible whole. Yet the part of a record that should go to the
model can be separated from the part that should not: the subscriber identity, the contact
information, the address and the access string can be removed and the rest sent. This is a defense
that blocks no record, so it has no false positive — its measure has to be read from somewhere else.
The next lesson counts how many records masking cleans and **how many tasks lose the field they
require**.

### Personal Data Masking

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/personal-data-masking

Reading the two numbers of reduction in the data that goes to the model together: flag-based masking removes a field from 32 of 240 records, and 14 of them belong to the hidden field request class and 9 are entirely clean records; the same policy makes 23 tasks that require the field unsolvable, and 7 of those 23 tasks belong to clean subscribers; 3 records of the hidden field request class do not carry the flag and so escape masking, the narrow policy raises the escaped count to 14 while lowering the broken tasks to 8, and the policy that masks every record misses no request but breaks 169 tasks, 104 of which belong to clean subscribers. Because masking blocks no record it has no false alarm, and reading it like a filter is misleading.

In two lessons the defense decided on the whole of the record: let through, block, or put a class
name. In all three the record was counted as an indivisible whole, and the measure was always read from
the same pair — the harmful record blocked and the clean record blocked by mistake. Yet the part of a
record that should go to the model can be separated from the part that should not.

Masking does exactly this: the record passes, and certain fields inside it do not. Because no record is
blocked, it has **no false alarm**, and that makes it look at first glance like a defense with no cost.
The cost is somewhere else. The removed field is not only the thing a harmful request is after; it is
also the thing some tasks **need in order to be solved**. This lesson puts those two numbers side by
side: how many records masking cleans and how many tasks it deprives of the field they require.

- **DF15.** The record set, the classes, the secret field classes, the task definition and the
  masking are **fictional**; no real personal data is processed and no real model endpoint is called.
  The seed is **20260218**, the second draw uses **20260219**.
- **DF16.** **This is not an attack lesson.** All of the code that runs sits on the defense side: field
  removal and counting. No working attack, payload or evasion routine is written.
- **DF17.** **An attack is not a text but a feature profile.** The `text` field of the records equals
  one of six ordinary support requests and **has no relation to the class**.
- **DF18.** The general routine of personal data, privacy and masking was measured in the Detection,
  Response and Compliance and Model Evaluation, Interpretability and Ethics courses and is not repeated
  here. This lesson gives only the number of **reduction in the data that goes to the model**.
- **DF19.** The baseline is the **setup without masking**: no field is removed, cleaned records 0,
  broken tasks 0.
- **DF20.** The task definition is fictional: of the six support texts, four need one of the secret
  field classes to be solved and two do not. When the field is removed, the task counts as **unsolvable**.
- **DF21.** The counts are independent of the run; masking produces no score, it looks at a structural
  flag. The step of the task rate is **0.0042**, the step of the clean record share is **0.0068**, the
  step of the hidden field request class is **0.0588**.

#### The Defense That Removes the Field

The first block sets up the course's shared definition and adds the fourth defense: the `SECRET_FIELD`
dictionary names the four secret field classes, and `redact` reports that the field was removed in the
records that carry a hidden field name. Masking returns no decision, it reports a **transformation**;
the record itself goes on its way.

```python
# FICTIONAL. The record set, classes, secret field classes and redaction are defined inside
# the lesson; no real personal data is processed and no real model endpoint is called.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# in the real world, well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 4: personal data masking (count only)
SECRET_FIELD = {"subscriber_id": "identity", "contact_info": "contact", "address": "location",
                "access_string": "access"}


def redact(k):
    """Fictional: in records that carry a hidden field name, the field is removed."""
    return {"redacted": k["features"]["hidden_field_name"]}


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}
```

#### Cleaned Record and Broken Task

To measure the cost, the record has to have a **job** as well. The second block ties the six support
texts to tasks and writes which secret field class each task needs: the questions about the invoice
amount and the meter reading ask for the subscriber identity, the outage question asks for the address,
the crew for the fault record asks for the contact information; the tariff step and the address change
procedure ask for no field, because both are general explanations. Then three masking policies run
side by side.

```python
# FICTIONAL task definition: each support text is a task and some of them need a field from a
# secret field class to be solved.
TASK_FIELD = {TEXT[0]: "subscriber_id", TEXT[1]: "subscriber_id", TEXT[2]: "address",
              TEXT[3]: None, TEXT[4]: "contact_info", TEXT[5]: None}


def policy(name):
    """Three redaction policies. None blocks; all remove a field."""
    if name == "none":
        return lambda k: False
    if name == "flag":
        return lambda k: redact(k)["redacted"]
    if name == "narrow":
        return lambda k: redact(k)["redacted"] and k["source"] != "user"
    return lambda k: True


def count(records, f):
    m = [k for k in records if f(k)]
    broken = [k for k in m if TASK_FIELD[k["text"]]]
    escaped = [k for k in records if k["class"] == "hidden_field_request" and not f(k)]
    return (len(m), sum(1 for k in m if not harmful(k)), len(broken),
            sum(1 for k in broken if not harmful(k)), len(escaped))


required = sum(1 for k in RECORDS if TASK_FIELD[k["text"]])
print("tasks that need a field:", required, "/", len(RECORDS),
      " hidden field request class:", sum(1 for k in RECORDS if k["class"] == "hidden_field_request"))
print()
print(f"{'policy':<10}{'masked':>11}{'of clean':>10}{'broken task':>15}"
      f"{'of clean':>10}{'escaped request':>16}")
for name in ("none", "flag", "narrow", "all"):
    print(f"{name:<10}" + "".join(f"{v:>{g}}" for v, g in
                                  zip(count(RECORDS, policy(name)), (11, 10, 15, 10, 16))))
print()
mask = [k for k in RECORDS if redact(k)["redacted"]]
print("classes of the records the flag policy masked:",
      {s: sum(1 for k in mask if k["class"] == s) for s in CLASSES
       if sum(1 for k in mask if k["class"] == s)})
print("redaction read as a filter:", measure(RECORDS, lambda k: redact(k)["redacted"]))
print("field class requested by the broken tasks:",
      {a: sum(1 for k in mask if TASK_FIELD[k["text"]] == a) for a in SECRET_FIELD
       if sum(1 for k in mask if TASK_FIELD[k["text"]] == a)})
print("field class no task requests:",
      [a for a in SECRET_FIELD if a not in TASK_FIELD.values()])
```

```
tasks that need a field: 169 / 240  hidden field request class: 17

policy         masked  of clean    broken task  of clean escaped request
none                0         0              0         0              17
flag               32         9             23         7               3
narrow             11         2              8         2              14
all               240       147            169       104               0

classes of the records the flag policy masked: {'clean': 9, 'indirect_instruction': 4, 'hidden_field_request': 14, 'privilege_escalation': 2, 'source_spoofing': 2, 'corrupted_input': 1}
redaction read as a filter: {'tp': 23, 'fp': 9, 'fn': 70, 'tn': 138, 'precision': 0.7188, 'recall': 0.2473, 'false_block_rate': 0.0612}
field class requested by the broken tasks: {'subscriber_id': 10, 'contact_info': 6, 'address': 7}
field class no task requests: ['access_string']
```

#### Reading the Two Numbers Together

The baseline row is as expected: with no masking the cleaned records are 0, the broken tasks are 0, and
**all 17 records** of the hidden field request class go to the model together with the field they ask
for.

The flag policy removes a field from **32** of the 240 records. The distribution of those 32 shows how
well the defense hits its target: **14** really belong to the hidden field request class, 9 to other
harmful classes and **9 are entirely clean** records. The second half of the same row writes the cost.
In **23** of the 32 masked records the task required the field, and because that field was deleted the
task becomes unsolvable; **7** of those 23 tasks belong to **clean subscribers**. This is the cost of
a defense that does not block the record: nobody was stopped, but twenty-three requests went
unanswered.

The third column pair writes the residue of the defense. **3 records** of the hidden field request
class escape masking, because those records do not carry the hidden field name flag. The profile of the
class carries this feature with a share of 0.86, not entirely. The scope of a defense cannot be larger
than the scope of the flag that triggers it — and that flag is not the same thing as the class itself
here.

The narrow policy is the opposite end: only the records that carry the flag and come from a source
other than the user are masked. Masked records fall from 32 to **11**, broken tasks from 23 to **8**,
the broken tasks of clean subscribers from 7 to 2. The gain is real, but the escaped requests rise from
3 to **14**: fourteen of the seventeen records of the class now go to the model with their field. What
shrinks together with the cost is the scope.

The last row shows the choice at the extreme. Masking every record brings the escaped requests down to
**zero** — the first place in this course where the residue of a defense comes out zero. Its cost is in
the same row: **169 tasks** are broken, that is, all of the tasks that need a field, and **104 of them
belong to clean subscribers**. 104 of the clean records, that is, 0.7075 of the 147, receive no answer.
Zero residue is attainable as a defense target, and it is usually a target that should not be attained.

The last two lines show that the cost is not spread equally over the field classes. Of the 23 broken
tasks, **10** asked for the subscriber identity, 7 for the address and 6 for the contact information.
The fourth secret field class, the access string, is asked for by **no task at all**. This means
masking is not a single on-off switch: a field class that no task needs can be masked **at no cost**,
while in the class that the most tasks need, every masking decision is the most expensive. The policy
that masks every record never sees this distinction and pays for all 169 tasks. To see the distinction,
it must be written down which task requires which field — this is not a security decision but a
product decision, and when it is not written down, the masking policy becomes unmeasurable.

The last output line is a warning. If we take masking for a filter and measure it with `measure`, we get
a precision of 0.7188 and a recall of 0.2473. These numbers are **meaningless**: masking stops no
record, so the 23 in the `tp` column are not "harmful records blocked" and the 9 in the `fp` column
are not a false alarm. Reading a defense that does not block with a blocking measure credits it with
both a success it does not deserve and a cost it does not pay. The two numbers of this defense are the
**cleaned records** and the **broken tasks**.

#### Independence from the Draw

Masking produces no score, has no threshold and asks no model; it looks at a structural flag. Its
decision is the same in every run for the same record. This does not mean the numbers are independent
of the set as well: the third block draws the same definition again with a second seed.

```python
def draw(seed):
    """Same definition, second draw: only the seed changes."""
    k2 = []
    for i in range(240):
        r = rng(seed + 6151 * i)
        c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
        source = SOURCES[pick(r(), SOURCE_SHARE[c])]
        feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
        k2.append({"no": i + 1, "class": c, "source": source, "features": feat,
                   "text": TEXT[int(r() * len(TEXT))],
                   "length": 40 + int(r() * 260)})
    return k2


print(f"{'draw':<10}{'harmful':>8}{'masked':>11}{'broken':>9}"
      f"{'broken/masked':>15}{'escaped request':>16}{'step':>8}")
for seed in (20260218, 20260219):
    kk = draw(seed)
    m, mc, br, brc, esc = count(kk, policy("flag"))
    n = sum(1 for k in kk if k["class"] == "hidden_field_request")
    print(f"{seed:<10}{sum(1 for k in kk if harmful(k)):>8}{m:>11}{br:>9}"
          f"{br / m:>15.4f}{str(esc) + '/' + str(n):>16}{1 / m:>8.4f}")
print()
print("task rate step:", round(1 / 240, 4),
      " clean record step:", round(1 / 147, 4))
```

```
draw       harmful     masked   broken  broken/masked escaped request    step
20260218        93         32       23         0.7188            3/17  0.0312
20260219        83         25       17         0.6800            1/11  0.0400

task rate step: 0.0042  clean record step: 0.0068
```

#### Summary

- Masking blocks no record; its two numbers are the **cleaned records** and the **broken tasks**, and
  reading it with a filter measure is misleading.
- The flag policy removes a field from 32 records — 14 of them from the hidden field request class, 9
  entirely clean — and makes the 23 tasks that require the field unsolvable; 7 of those 23 belong to
  clean subscribers.
- 3 records of the hidden field request class escape masking because they do not carry the flag: the
  scope of a defense cannot be larger than the scope of the flag that triggers it.
- The narrow policy lowers the broken tasks from 23 to 8 and raises the escaped requests from 3 to 14;
  the policy that masks every record brings the escaped requests to zero and breaks 169 tasks, 104 of
  them belonging to clean subscribers.
- 10 of the 23 broken tasks ask for the subscriber identity; no task asks for the access string, so
  that field class can be masked at no cost. Masking is not a single switch but a decision per field
  class.
- In the two draws the cleaned records are 32 and 25 and the broken tasks 23 and 17; the ratio stays
  between 0.7188 and 0.6800 and the order does not change.

#### Next Step

In three lessons the defense always worked **on the text**. The rule filter read the structural
features of the text, the content moderation step gave it a class name, masking removed a field from
inside it. The assumption common to all three was that checking the text entering the model is
enough. Yet the harm of a record is not in the text but in **the action the text triggers**: the tool
called, the record written, the message sent. How much authority a request that got past the filter
holds in its hands, how many harmful records narrowing that authority would stop and how many tasks it
would leave unfinished was never asked. The next lesson takes the defense off the text and puts it on
the **action**.

### Permissions and Sandboxing

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/permissions-and-sandboxing

Counting tool permission as a defense layer: the same fictional set of 240 records runs under five permission sets, and the harmful records stopped, the false alarms and the completed tasks are printed side by side. On the undefended baseline the harmful stopped is 0 and the false alarm is 0; closing the execution capability stops 12 harmful records, blocks no clean record and gives a precision of 1.0000; closing the side-effect capability gives 24 harmful against 3 false alarms; the allowlisted set stops 34 harmful and lowers the completed tasks to 0.8844, read-only stops 62 harmful and lowers them to 0.6667. The marginal precision of narrowing falls from 1.0000 to 0.4167: the last two steps block more than one clean record for every harmful record they stop. The class distribution shows the blind spot of the gate: 1.0000 of the privilege escalation class is stopped, only 0.0588 of the hidden field request class.

In three lessons the defense worked **on the text**. The input and output filters read a record and
flagged it, content moderation separated it per class, masking deleted the field going to the model.
What all three touched was the same: the **text** entering or leaving the model. What the agent **does**
after reading that text was never touched.

This lesson moves the defense from the text to the action. Even if a record passes the filter, it is
harmless if the capability it wants to touch was never granted. The only thing measured is this: as
permissions narrow, how many harmful records stop, how many clean records stop by mistake, and how
many tasks can still be completed.

- **DF41.** The code is a **simulator**; no real tool, file system or runtime is called. The record
  set, the classes and the features are **fictional**, the seed is **20260218**.
- **DF42. An attack is not a text but a feature profile.** The six sentences the records carry are
  ordinary support requests and have **no relation to the class**; the class lives only in the
  structural flags. Nowhere in the code is there a working payload, template or instruction string.
- **DF43. The baseline is the undefended setup:** no filter, marking or gate; the harmful stopped is
  **0**, the false alarm is **0**.
- **DF44. The rule of the course:** a defense that does not write the share it stopped and the share it
  stopped by mistake **together** counts as unmeasured.
- **DF45. The permission surface in this lesson is a pair:** the **capability** the tool declares
  (read, external access, write, execution) and the **scope** it can touch (the source class). The
  capability a record asks for is derived only from the flags that exist; no new text is added to the
  set.
- **DF46. The sandbox is the boundary this pair draws**, and the gate stands in front of the executor:
  a request that does not pass never reaches the tool. The gate **only stops**; there is no path that
  leaves the boundary.
- **DF47. The completed task** is the share of clean records that are not stopped: how many of the 147
  clean records can finish their job.
- **DF48. Resolution:** the step of recall is $1/93 = 0.0108$, the step of the false block rate is
  $1/147 = 0.0068$. There is no draw in the decision of the gate, so the measure is **independent of the
  run**; the same set gives the same number in every run.

#### Shared Definition

The record set, the classes, the defense layers and the measure are the same throughout the course. The
set holds 240 fictional support records; each record has a class, a source and seven structural
features. The measure function `measure` returns every decision with five numbers and never separates
what is stopped from what is stopped by mistake.

```python
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
# Feature probability per class (fictional). Clean records carry small shares too:
# well-meant text can carry these features as well.
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


print("records:", len(RECORDS), "- harmful:", sum(harmful(k) for k in RECORDS),
      "- clean:", sum(not harmful(k) for k in RECORDS))
print("source:", {s: sum(k["source"] == s for k in RECORDS) for s in SOURCES})
```

```
records: 240 - harmful: 93 - clean: 147
source: {'user': 137, 'retrieved_document': 72, 'tool_output': 31}
```

#### The Permission Surface and the Boundary of the Sandbox

The principle of least privilege was established in the Introduction to Cybersecurity course, sandboxes
and allowlists were measured in the Endpoint and System Hardening course, and file permissions in the
Introduction to Linux course. The AI Agents course measured the same gate in the agent loop: at full
permission 0.9583 of the tasks were completed, in the allowlisted set 0.8333, and the gate stopped
20 calls. None of these procedures is repeated; the numbers are input. The question here is
different: how many harmful records does the same gate stop as a **defense**, and what does it cost.

Note that the gate only stops. No path that crosses the boundary is defined in the code;
the `sandbox` function decides whether a request can pass, and a request that does not pass never
reaches the tool. The five permission sets narrow step by step: each row is a subset of the one before.

```python
# ---- Specific to this lesson: the CAPABILITY a record wants to touch is read from existing flags
def requested_capability(k):
    """No new text or payload is added; the capability is derived only from structural flags."""
    if k["features"]["side_effect_tool_name"]:
        return "execution" if k["features"]["instruction_mode"] else "write"
    if k["source"] != "user":
        return "external_access"
    return "read"


ALL_CAPABILITIES = ("read", "external_access", "write", "execution")
ALL_SCOPES = ("user", "retrieved_document", "tool_output")
PERMISSIONS = {
    "full access":     (ALL_CAPABILITIES, ALL_SCOPES),
    "no execution":    (("read", "external_access", "write"), ALL_SCOPES),
    "no side effects": (("read", "external_access"), ALL_SCOPES),
    "allowlisted":     (("read", "external_access"), ("user", "retrieved_document")),
    "read-only":       (("read",), ("user",)),
}


def sandbox(k, p):
    """The gate stands in front of the executor and ONLY stops; there is no path
    that leaves the boundary. A request that does not pass never reaches the tool."""
    capability, scope = p
    return requested_capability(k) not in capability or k["source"] not in scope


print("requested capability:", {g: sum(requested_capability(k) == g for k in RECORDS)
                                for g in ALL_CAPABILITIES})
print("\npermission set   block   false  precision      recall  false-block   task   marginal")
prev_tp = prev_fp = 0
for name, p in PERMISSIONS.items():
    s = measure(RECORDS, lambda k, p=p: sandbox(k, p))
    d_tp, d_fp = s["tp"] - prev_tp, s["fp"] - prev_fp
    marginal = f"{d_tp / (d_tp + d_fp):.4f}" if d_tp + d_fp else "-"
    print(f"{name:<15} {s['tp']:>6} {s['fp']:>7} {s['precision']:>10.4f} {s['recall']:>11.4f}"
          f" {s['false_block_rate']:>12.4f} {s['tn'] / 147:>6.4f} {marginal:>10}")
    prev_tp, prev_fp = s["tp"], s["fp"]

print("\nshare stopped per class (allowlisted set)")
for s in CLASSES[1:]:
    g = [k for k in RECORDS if k["class"] == s]
    d = sum(sandbox(k, PERMISSIONS["allowlisted"]) for k in g)
    print(f"  {s:<20} {d:>2}/{len(g):<3} {d / len(g):.4f}")


def allowlisted(k):
    return sandbox(k, PERMISSIONS["allowlisted"])


print("\nlayer on layer            block   false  precision      recall  false-block")
for name, decision in (("rule filter", rule_filter),
                       ("permission gate", allowlisted),
                       ("rule OR permission", lambda k: rule_filter(k) or allowlisted(k))):
    s = measure(RECORDS, decision)
    print(f"{name:<24} {s['tp']:>6} {s['fp']:>7} {s['precision']:>10.4f}"
          f" {s['recall']:>11.4f} {s['false_block_rate']:>12.4f}")
added = [k for k in RECORDS if allowlisted(k) and not rule_filter(k)]
print("added by permission only:", len(added), "records,",
      sum(harmful(k) for k in added), "harmful")
```

```
requested capability: {'read': 129, 'external_access': 84, 'write': 15, 'execution': 12}

permission set   block   false  precision      recall  false-block   task   marginal
full access          0       0     0.0000      0.0000       0.0000 1.0000          -
no execution        12       0     1.0000      0.1290       0.0000 1.0000     1.0000
no side effects     24       3     0.8889      0.2581       0.0204 0.9796     0.8000
allowlisted         34      17     0.6667      0.3656       0.1156 0.8844     0.4167
read-only           62      49     0.5586      0.6667       0.3333 0.6667     0.4667

share stopped per class (allowlisted set)
  indirect_instruction 14/24  0.5833
  hidden_field_request  1/17  0.0588
  privilege_escalation 10/10  1.0000
  source_spoofing       6/23  0.2609
  corrupted_input       3/19  0.1579

layer on layer            block   false  precision      recall  false-block
rule filter                  74      23     0.7629      0.7957       0.1565
permission gate              34      17     0.6667      0.3656       0.1156
rule OR permission           78      37     0.6783      0.8387       0.2517
added by permission only: 18 records, 4 harmful
```

#### The Marginal Value of Narrowing

The first row is the baseline: the gate stops nothing, all 93 harmful records pass and all 147 clean
records finish their job. Every row after that stops something and pays a price.

The second row is the cheapest gain of this lesson. Closing the execution capability stops **12**
harmful records, blocks **no** clean record, leaves the precision at **1.0000** and the completed task
at **1.0000**. The reason is in the structure: the execution capability requires two flags to be
present together, and that is not where clean traffic goes. This is the measurable core of the
principle of least privilege — **closing a capability that clean traffic does not visit is a free
defense.**

The last column says where that ends. **Marginal precision** is the ratio of the extra harmful records
a narrowing step stops to the total extra records stopped in that step. The sequence runs 1.0000,
0.8000, 0.4167, 0.4667. At the third step the ratio falls **below 0.5000**: moving to the allowlist
stops 10 more harmful records while blocking 14 more clean records. The fourth step is heavier still —
32 clean records for 28 harmful. This is the evidence this lesson contributes to the second claim of
the course: **past a certain point, continuing to narrow stops more tasks than harmful records.** The
number is still growing, because recall rises from 0.3656 to 0.6667; but in the same step the completed
task drops from 0.8844 to 0.6667. Reading recall alone would count this step as an
improvement.

Resolution tells whether the differences are measured. The last jump of 0.3011 in recall is twenty-eight
times the step of 0.0108; the drop of 0.2177 in the completed task is thirty-two times the step of
0.0068. Both are far above the band. On the other hand, the difference in false blocks between
`no execution` and `full access` is **exactly zero**, and that is not rounding but the count itself.

#### Where the Gate Is Blind

The last block opens what an average hides. The allowlisted set gives a recall of 0.3656 in
general, but the distribution per class is not flat at all: **1.0000** of the `privilege_escalation`
class is stopped, 0.5833 of the `indirect_instruction` class, and only **0.0588** of the
`hidden_field_request` class.

The reason is what the gate measures. The permission gate looks at **what a record wants to do**, not
**what it says**. A record carrying a hidden field request mostly asks only for the read capability;
read stays open in every set, because when it is closed no task remains. The permission gate is blind to
that class by its structure, and this blindness is not closed by narrowing.

Two conclusions follow. First, the permission layer does **not take the place** of a filter: the two
layers look at different classes, and the recall of one does not fill the blind spot of the other.
Second, reporting a defense with a single average rate is misleading — the number 0.3656 contains both
1.0000 and 0.0588 and does not say which attack is stopped by which layer.

#### Layer on Layer

The first answer that comes to mind for the blind spot is to stack two layers: let the rule filter look
at the text and the permission gate at the action, and a record that is caught by either is enough. The
last table gives the number, and the result is the opposite of what was expected.

The rule filter alone stops 74 harmful records and blocks 23 clean records. Putting the permission gate
on top raises recall from 0.7957 to **0.8387**, that is, four more harmful records. In the same step the
false block rate rises from 0.1565 to **0.2517** and the precision falls from 0.7629 to 0.6783. The last
line says why: of the 18 records that the permission gate **adds** to the filter, only **4** are harmful
and 14 are clean. The marginal precision of the layer is $4/18 = 0.2222$.

Four harmful records are four times the step of 0.0108 and are a measured gain; fourteen clean records
are fourteen times the step of 0.0068 and are a measured loss. Both are real; which one weighs more is
read not from the number but from how expensive a blocked task is. The one thing the number says for
certain is this: **stacking two defenses does not enlarge recall for free.** Reporting this table with
the recall column alone would have made the gain of the added layer look larger than it is.

#### Summary

- The permission surface is the pair of the **capability** a tool declares and the **scope** it can
  touch; the sandbox is the boundary this pair draws, and the gate stands in front of the executor and
  only stops.
- On the undefended baseline the harmful stopped is 0 and the false alarm is 0; closing the execution
  capability stops 12 harmful records and blocks no clean record, with precision 1.0000 and completed
  task 1.0000. The allowlisted set stops 34 harmful and lowers the task to 0.8844, read-only stops 62
  and lowers it to 0.6667: while recall rises to 0.6667, a third of the tasks disappear.
- Marginal precision falls from 1.0000 to 0.4167: the last two narrowing steps block more than one
  clean record for every harmful record they stop.
- The permission gate is class-selective: it stops 1.0000 of the `privilege_escalation` class and only
  0.0588 of the `hidden_field_request` class; a single average rate hides this distinction.
- Putting the permission gate on top of the rule filter raises recall from 0.7957 to 0.8387 while it
  moves the false block rate from 0.1565 to 0.2517; of the 18 added records only 4 are harmful.
- There is no draw in the decision of the gate, the measure is independent of the run; the recall step
  is 0.0108, the false block step is 0.0068.

#### Next Step

Every defense in this lesson worked in the same way: it **stopped** a record. The cost of stopping is
also written in the table — the four narrowing steps blocked 49 clean records in total. Yet stopping is
not the only way to neutralize a harmful record. The harm of a record often comes not from its content
but from that content **being read as an instruction**; if the text that comes from a retrieved document
is counted as data, it becomes harmless without being blocked at all. The next lesson measures this
defense that does not block and counts two things: how many of the 93 harmful records marking
neutralizes without stopping any record, which classes remain, and what the false alarm counterpart of
this is.

### Untrusted Content Marking

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/flagging-untrusted-content

Counting a defense that blocks no record: text coming from a source other than the user is marked as data and is not counted as an instruction even if it carries the instruction mode. On the undefended baseline the harmful stopped is 0 and the false alarm is 0; marking neutralizes 42 of the 93 harmful records without blocking any record, 51 remain, and the false alarm is zero. Neutralization gathers in two classes: 0.9167 of indirect instruction, 0.8696 of source spoofing; 0.0000 in the hidden field request, privilege escalation and corrupted input classes. The real value of marking is that it lets the filter be tightened: the rule filter alone covers 0.7957 and blocks 23 clean records, marking with the 0.50 threshold of the model filter covers 0.7634 and blocks only 8 clean records. The cost is not a false alarm: 47 of the 103 marked records are clean and only 1 of them carries the instruction mode, so the path lost is a single record and exactly one step of the resolution.

The previous lesson measured the permission gate and counted that the four narrowing steps blocked 49
clean records in total. Every defense in that lesson worked in the same way: it **stopped** a record.
The filter, content moderation, masking and the permission gate each protected by not letting
something through, and the cost of all four came from the same place — the clean record blocked by
mistake.

Yet stopping is not the only way to neutralize a harmful record. The harm of text that comes from a
retrieved document often comes not from its content but from that content **being read as an
instruction**. If the text is counted as data, the same record becomes harmless without being blocked at
all. This lesson measures that defense and asks a single question: how many of the 93 harmful records
does a layer that stops no record cover, and what does it pay in return.

- **DF49.** The code is a **simulator**; no real model endpoint or document store is called. The
  record set, the classes and the features are **fictional**, the seed is **20260218**.
- **DF50. An attack is not a text but a feature profile.** The six sentences the records carry are
  ordinary support requests and have **no relation to the class**; the class lives only in the
  structural flags. There is no working payload, template or instruction string in the code.
- **DF51. The baseline is the undefended setup:** the harmful stopped is **0**, the false alarm is
  **0**.
- **DF52. The rule of the course:** a defense that does not write the share it stopped and the share it
  stopped by mistake **together** counts as unmeasured.
- **DF53. Marking is not a block.** Text coming from a source other than the user is stamped as
  **data**; even if it carries the instruction mode, it is not counted as an instruction. No record is
  stopped, no record is deleted, no field is removed.
- **DF54. The neutralized record** is a record that is in a class whose harm rests on being read as an
  instruction and that comes from a source other than the user. Both conditions are required together.
- **DF55. The covered harmful** is the **union** of what marking neutralizes and what the filter blocks;
  the same record is not counted twice.
- **DF56. The cost of marking is not a false alarm**, because the blocked records are zero. The cost is
  the **instruction path** that a clean record counted as data loses, and it is counted separately.
- **DF57. Resolution:** the step of recall is $1/93 = 0.0108$, the step of the false block rate is
  $1/147 = 0.0068$. There is no draw in the decision of marking and the measure is **independent of the
  run**.

#### Shared Definition

The record set, the classes, the defense layers and the measure are the same throughout the course. The
set holds 240 fictional support records; the source field says where a record comes from, and marking
looks only at this field.

```python
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 2: model-based filter (score + threshold)
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


# ---- DEFENSE 3: untrusted content marking (not counted as an instruction)
def mark(k):
    """Text that comes from a source other than the user is marked as DATA;
    even if it carries the instruction mode, it is not counted as an instruction."""
    return {"as_data": k["source"] != "user"}


def marking_effect(records):
    """Marking closes only the 'read as an instruction' path."""
    neutralized, remaining = 0, 0
    for k in records:
        if not harmful(k):
            continue
        instruction_based = k["class"] in ("indirect_instruction", "source_spoofing")
        if instruction_based and mark(k)["as_data"]:
            neutralized += 1
        else:
            remaining += 1
    return neutralized, remaining


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


print("records:", len(RECORDS), "- harmful:", sum(harmful(k) for k in RECORDS))
print("source:", {s: sum(k["source"] == s for k in RECORDS) for s in SOURCES})
```

```
records: 240 - harmful: 93
source: {'user': 137, 'retrieved_document': 72, 'tool_output': 31}
```

#### The Data-Instruction Distinction

Marking breaks a single assumption: that all the text entering the model is read with the same weight.
The user's own request is an instruction, because the user is the will the agent serves. Text that
comes from a retrieved document or a tool output is **data** — it is read, quoted, summarized, but it
does not change behavior. The `mark` function puts this stamp by looking only at the source field and
stops no record.

Whom the distinction affects depends on the class. If the harm of a record rests on **being read as an
instruction**, the stamp removes that harm. If the harm rests on something else, the stamp does
nothing: asking for a hidden field name is a request even when it is read as data.

```python
def neutralized(k):
    """A record whose harm rests on BEING READ AS AN INSTRUCTION and that comes from a source
    other than the user stays neutral without anything being blocked: the text is counted
    as data, not as an instruction."""
    return (harmful(k) and k["class"] in ("indirect_instruction", "source_spoofing")
            and mark(k)["as_data"])


neutralized_count, remaining = marking_effect(RECORDS)
marked = [k for k in RECORDS if mark(k)["as_data"]]
clean_marked = [k for k in marked if not harmful(k)]
print("baseline    : harmful stopped 0 , false alarm 0")
print("marking     : neutralized", neutralized_count, ", remaining harmful", remaining,
      ", covered share", round(neutralized_count / 93, 4))
print("marked records", len(marked), "- clean among them", len(clean_marked),
      "- carrying instruction mode", sum(k["features"]["instruction_mode"] for k in clean_marked))
print("blocked records 0 - false alarm 0")

print("\nper-class share neutralized by marking")
for s in CLASSES[1:]:
    g = [k for k in RECORDS if k["class"] == s]
    e = sum(neutralized(k) for k in g)
    print(f"  {s:<20} {e:>2}/{len(g):<3} {e / len(g):.4f}")


def setup(decision=None, marking=True):
    """Covered harmful = neutralized by marking OR blocked by the filter."""
    s = measure(RECORDS, decision) if decision else {"tp": 0, "fp": 0, "false_block_rate": 0.0}
    covered = sum(1 for k in RECORDS if harmful(k)
                  and ((marking and neutralized(k)) or (decision is not None and decision(k))))
    return covered, s["tp"] + s["fp"], s["fp"], s["false_block_rate"]


print("\nsetup                    covered   share     blocked   false alarm false-block")
for name, decision, mk in [("baseline", None, False),
                           ("marking only", None, True),
                           ("rule filter only", rule_filter, False),
                           ("marking + rule", rule_filter, True),
                           ("marking + model 0.50", lambda k: model_filter(k, 0.50), True),
                           ("marking + model 0.60", lambda k: model_filter(k, 0.60), True),
                           ("marking + model 0.80", lambda k: model_filter(k, 0.80), True)]:
    covered, blocked, fa, fbr = setup(decision, mk)
    print(f"{name:<24} {covered:>7} {covered / 93:>7.4f} {blocked:>11} {fa:>13} {fbr:>11.4f}")
```

```
baseline    : harmful stopped 0 , false alarm 0
marking     : neutralized 42 , remaining harmful 51 , covered share 0.4516
marked records 103 - clean among them 47 - carrying instruction mode 1
blocked records 0 - false alarm 0

per-class share neutralized by marking
  indirect_instruction 22/24  0.9167
  hidden_field_request  0/17  0.0000
  privilege_escalation  0/10  0.0000
  source_spoofing      20/23  0.8696
  corrupted_input       0/19  0.0000

setup                    covered   share     blocked   false alarm false-block
baseline                       0  0.0000           0             0      0.0000
marking only                  42  0.4516           0             0      0.0000
rule filter only              74  0.7957          97            23      0.1565
marking + rule                77  0.8280          97            23      0.1565
marking + model 0.50          71  0.7634          74             8      0.0544
marking + model 0.60          67  0.7204          64             5      0.0340
marking + model 0.80          60  0.6452          47             1      0.0068
```

#### The Number of a Defense That Does Not Block

The second row is the most striking row of this course. Marking neutralizes **42** of the 93 harmful
records — the covered share is **0.4516** — and it does so **without blocking any record**. The
blocked records column is 0, the false alarm column is 0, the false block rate is 0.0000. The rule of
the course asks for the two numbers of a defense together; the second number of this defense is exactly
zero, and this is not a rounding but the count itself.

For comparison the third row has to be read. The rule filter covers more — **0.7957** — but it blocks
97 records for this, and 23 of those blocks are on clean records. The two defenses are not of the same
kind: one buys coverage with blocking, the other buys it free.

The main finding is in the last three rows. Marking **allows the threshold of the filter behind it to
be tightened**. Because most of the instruction-based harmful is already neutralized, the filter does
not need to catch them; it can focus only on the remaining 51 records. Marking together with the 0.50
threshold of the model filter covers **0.7634** and blocks only **8** clean records. The difference from
the 0.7957 of the rule filter alone is three records, that is $3 \times 0.0108 = 0.0323$; in return the
false block rate falls from 0.1565 to 0.0544, that is, fifteen fewer clean records are blocked, and this
is $15 \times 0.0068 = 0.1021$. A coverage difference of three records is given in exchange for a
blocking difference of fifteen records. In the tightest row, at the 0.80 threshold, the blocked clean
records fall to **one** and the coverage still stays at 0.6452.

The fourth row shows one more thing: putting marking on top of the rule filter raises the coverage from
0.7957 to 0.8280 and **does not change the false block rate at all**. In the previous lesson, putting the
permission gate on top of the filter raised recall by 0.0430 while it raised the false block rate by
0.0952. The difference comes from the kind of layer: stacking a layer that does not block is free.

#### The Wall of Marking

The class distribution says plainly where the defense ends. Neutralization happens in only two classes:
**0.9167** of the `indirect_instruction` class and **0.8696** of the `source_spoofing` class. In the
other three classes the number is **0.0000**. This is not a shortcoming but the consequence of the
definition: marking closes only the "read as an instruction" path, and a hidden field request,
privilege escalation or corrupted input does not pass through that path.

Of the 51 remaining records, 46 come from these three classes. The remaining **5** records are in the
first two classes but their source is **the user** — 2 of the indirect instruction, 3 of the source
spoofing. Marking cannot touch them by its structure, because counting the user's own text as data
leaves the agent useless. In the same set there are 37 harmful records from the user; this is the area
this layer cannot cover, and it is the limit that holds the coverage at 0.4516.

The cost column being zero does not mean the defense costs nothing. **47** of the 103 marked records
are clean, and these records too are counted as data. Among them there is **one** record that carries
the instruction mode: the only clean record that comes from a retrieved document and really asks for an
operation. Marking closes its path as well. The capability lost is $1/147 = 0.0068$, that is, a whole
step of the resolution of the set — the smallest measurable cost. Not writing this cost would show the
defense as free; writing it is the only way to show how small it is.

#### Summary

- Untrusted content marking stamps text coming from a source other than the user as **data**; even if
  it carries the instruction mode it is not counted as an instruction, and no record is stopped.
- Marking neutralizes 42 of the 93 harmful records (covered share 0.4516), 51 harmful remain, and the
  blocked records and the false alarm are **zero**.
- Neutralization gathers in two classes: `indirect_instruction` 0.9167, `source_spoofing` 0.8696; in
  the other three classes it is 0.0000, because their harm does not rest on being read as an
  instruction.
- Marking allows the threshold of the filter to be tightened: marking with the 0.50 threshold of the
  model filter covers 0.7634 and blocks 8 clean records; the rule filter alone covers 0.7957 and blocks
  23 clean records.
- Putting marking on top of the rule filter raises the coverage to 0.8280 and does not change the false
  block rate at all; adding a layer that does not block is free.
- The cost is not zero, it is the smallest: 47 of the 103 marked records are clean and the 1 of them
  that carries the instruction mode loses its path, that is 0.0068 — a whole step of the resolution.

#### Next Step

Marking enlarged the coverage for free, but its wall was clear: it touches none of the remaining 51
records, and the most expensive class stands among them. Records that carry a privilege escalation or a
hidden field request lead to operations that cannot be taken back once they pass; a deleted record, a
written amount or a piece of information gone outside cannot be corrected afterwards. For such an
action the certainty of an automatic decision is not enough. The next lesson opens the gate to a human
and counts two things: how many of the records sent to the human are really harmful, and how many
harmful records the gate **fails to catch** as the number of questions asked grows.

### Actions Requiring Human Approval

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/actions-requiring-human-approval

Counting the precision and cost of a human approval gate on irreversible actions: the narrow gate sends 27 records to a human, 24 of them really harmful, precision 0.8889, recall 0.2581, 3 unnecessary interruptions. Widening raises recall to 0.7957 under unlimited attention, but under a fixed attention budget of 27 prompts the harmful caught falls to 23 and 17 in two runs and never passes the 24 of the narrow gate. As the review share goes from 1.00 to 0.20 the harmful caught falls from 24 to 4 and recall from 0.2581 to 0.0430. The cost is not a blocked task but an interruption: blocking the same 27 records would lose three clean tasks; asking loses none.

The previous lesson counted that a defense that does not block neutralizes 42 of the 93 harmful records
for free, and showed its wall: it does not touch the remaining 51. Among them are the records that
carry a privilege escalation and a hidden field request, and the target of these two classes is most
often the same place — an operation that cannot be taken back once it happens.

An **irreversible action** is an action whose result cannot be corrected afterwards: a record written,
a notification sent, information that has gone outside. For such an action a precision of 0.8889 is not
enough, because the cost of a wrong decision is asymmetric. This lesson opens the gate to a human and
counts three things: how many of the records sent to a human are really harmful, what happens as the
gate widens, and what is left of the gate as the human's attention decreases.

- **DF58.** The code is a **simulator**; no real tool or approval interface is called. The record set,
  the classes and the features are **fictional**, the seed is **20260218**, the second run
  **20260219**.
- **DF59. An attack is not a text but a feature profile.** The six sentences the records carry are
  ordinary support requests and have **no relation to the class**. There is no working payload,
  template or instruction string in the code.
- **DF60. The baseline is the undefended setup:** records sent to a human **0**, harmful caught **0**,
  unnecessary interruption **0**.
- **DF61. The rule of the course:** a defense that does not write the share it stopped and the share it
  stopped by mistake **together** counts as unmeasured.
- **DF62. The irreversible action request** is represented in this set by the `side_effect_tool_name`
  flag; the `approval_gate` function of the shared definition looks at exactly this flag.
- **DF63. The gate does not block, it asks.** If approval is given the action proceeds, if not it does
  not. The cost of the gate is therefore not a blocked task but an **interruption**: every record sent
  to a human creates a wait, and some are unnecessary.
- **DF64. The review order of the human is not related to harm.** The order is fictional and differs
  between the two runs; this is the only source of the run band of the gate.
- **DF65. Attention is a budget.** The human can really review at most **27** prompts; prompts outside
  the budget are approved unseen. The budget is fictional, set equal to the prompts of the narrow gate.
- **DF66. Resolution:** the step of recall is $1/93 = 0.0108$, the step of the false block rate is
  $1/147 = 0.0068$; no difference smaller than these steps counts as a finding.

#### Shared Definition

The record set, the classes, the defense layers and the measure are the same throughout the course. In
this lesson two defenses of the shared definition are used: the rule filter and the human approval gate.

```python
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL records: no attack TEXT is written, an attack is a FEATURE PROFILE
CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
# Features are structural; none of them is a working payload.
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class;
# the class information lives only in the feature profile.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


# ---- DEFENSE 1: rule filter (structural, explainable)
def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


# ---- DEFENSE 5: human approval gate (irreversible actions)
def approval_gate(k, required=True):
    """A record that carries a side-effecting tool request is sent to a human."""
    return required and k["features"]["side_effect_tool_name"]


# ---- MEASURE: what was blocked and what was blocked by mistake are written together
def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


print("records:", len(RECORDS), "- harmful:", sum(harmful(k) for k in RECORDS))
print("sent to approval gate:", sum(approval_gate(k) for k in RECORDS))
```

```
records: 240 - harmful: 93
sent to approval gate: 27
```

#### The Precision and the Width of the Gate

The AI Agents course measured the approval point in the agent loop: with approval off, 3 wrong records
are written, with approval on, 1; the loop had 7 approval points. That procedure is not repeated; its
numbers are taken as input. The question here is different — what the gate gives when it is measured
like a **classifier**.

The width of the gate is a design decision: which flags a record must carry to be asked of a human.
Four widths are tried; the narrowest is the `approval_gate` function of the shared definition, the
widest is the rule set of the rule filter itself. The difference lies in the kind of defense: the
filter blocks, the gate asks.

```python
RULE_SET = ("instruction_mode", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch")
GATE = {"narrow":  ("side_effect_tool_name",),
        "medium":  ("side_effect_tool_name", "hidden_field_name"),
        "wide":    ("side_effect_tool_name", "hidden_field_name", "source_stamp_mismatch"),
        "widest":  RULE_SET}
BUDGET = 27


def asks(k, fields):
    """The gate does not block; it sends the record to a human and waits for the answer."""
    return any(k["features"][o] for o in fields)


def review_order(records, seed):
    """The order in which the human looks is not related to harm; it is a fictional order."""
    return sorted(records, key=lambda k: rng(seed ^ trace_id(k["no"]))())


print("narrow gate is the approval gate of the shared definition:",
      all(asks(k, GATE["narrow"]) == approval_gate(k) for k in RECORDS))
print("widest gate is the rule set of the rule filter:",
      all(asks(k, GATE["widest"]) == rule_filter(k) for k in RECORDS))
print("baseline: sent to a human 0 , harmful caught 0 , unnecessary interruption 0\n")
print("gate      prompts  harmful precision      recall  needless reviewed   A shareA   B shareB")
for name, fields in GATE.items():
    s = measure(RECORDS, lambda k, fields=fields: asks(k, fields))
    q = [k for k in RECORDS if asks(k, fields)]
    n = min(BUDGET, len(q))
    a = sum(harmful(k) for k in review_order(q, SEED)[:n])
    b = sum(harmful(k) for k in review_order(q, 20260219)[:n])
    print(f"{name:<9} {s['tp'] + s['fp']:>7} {s['tp']:>8} {s['precision']:>9.4f}"
          f" {s['recall']:>11.4f} {s['fp']:>9} {n:>8} {a:>3} {a / 93:.4f} {b:>3} {b / 93:.4f}")

q = [k for k in RECORDS if approval_gate(k)]
print("\nnarrow gate as the review share falls (27 prompts)")
print(" review share reviewed     caught A shareA     caught B shareB passed harmful")
for p in (1.00, 0.80, 0.60, 0.40, 0.20, 0.00):
    n = int(p * len(q))
    a = sum(harmful(k) for k in review_order(q, SEED)[:n])
    b = sum(harmful(k) for k in review_order(q, 20260219)[:n])
    print(f"{p:>13.2f} {n:>8} {a:>12} {a / 93:>6.4f} {b:>12} {b / 93:>6.4f} {93 - a:>14}")
```

```
narrow gate is the approval gate of the shared definition: True
widest gate is the rule set of the rule filter: True
baseline: sent to a human 0 , harmful caught 0 , unnecessary interruption 0

gate      prompts  harmful precision      recall  needless reviewed   A shareA   B shareB
narrow         27       24    0.8889      0.2581         3       27  24 0.2581  24 0.2581
medium         56       44    0.7857      0.4731        12       27  22 0.2366  18 0.1935
wide           78       62    0.7949      0.6667        16       27  22 0.2366  19 0.2043
widest         97       74    0.7629      0.7957        23       27  23 0.2473  17 0.1828

narrow gate as the review share falls (27 prompts)
 review share reviewed     caught A shareA     caught B shareB passed harmful
         1.00       27           24 0.2581           24 0.2581             69
         0.80       21           18 0.1935           18 0.1935             75
         0.60       16           13 0.1398           13 0.1398             80
         0.40       10            8 0.0860            8 0.0860             85
         0.20        5            4 0.0430            4 0.0430             89
         0.00        0            0 0.0000            0 0.0000             93
```

#### The Cost of the Gate Is Interruption

The first row is the precision of the gate: **24** of the **27** records sent to a human are really
harmful, precision **0.8889**. This is among the highest precisions measured in this course, for a
narrow reason — the gate looks at a single flag that clean traffic almost never visits. Its recall is
only **0.2581**; the gate is not a filter and does not try to be one.

The cost column is where the gate parts from the filter. The gate blocks no record, it asks; if
approval is given the action proceeds. The cost is therefore not a blocked task but **27
interruptions**, and **3** of them are unnecessary. The previous lesson gives the comparison: had the
permission gate blocked the same 27 records, the completed task would have fallen from 1.0000 to
0.9796, that is, three clean tasks would have been lost. When asked, none is lost. The gate turns one
false block into one unnecessary interruption; whether this is a good exchange depends on the waiting
being cheaper than the lost task, and this is not a number but a decision.

A consequence is that the gate **adds nothing in coverage**. Its flag is one of the four rules of the
rule filter, so the 27 records sent to a human are a subset of the 97 records the filter flags. If the
filter is in place, the gate catches no harmful record for the first time; it applies **another
operation** to those 27 records. Where the classifier's precision is stuck at 0.8889, handing the
decision to a human may be the only remedy for an irreversible action — but this is not a gain in recall and reporting it as one would be wrong.

#### The Backfire of Widening

The next three rows widen the gate. The recall column brings good news: 0.2581 rises to 0.4731, 0.6667
and 0.7957. But this column assumes **unlimited attention** — that the human really looks at all 97
prompts. The reviewed column removes that assumption: the budget is 27 prompts, and in a widened gate
more than half of the prompts are approved without being reviewed at all.

The columns of the harmful really caught reverse the result. The narrow gate catches **24** in both
runs; the medium gate 22 and 18, the wide gate 22 and 19, the widest gate 23 and 17. **Under a fixed
attention budget no widening passes the 24 of the narrow gate**, in either run. In the same step the
unnecessary interruptions rise from 3 to 23. This is the evidence this lesson contributes to the second
claim of the course: **widening the gate, with attention fixed, lowers what is caught and multiplies
the interruptions.**

The run band limits the measure here. The narrow gate gives the same number in both runs, its band is
zero. In the widened gates the difference between the two runs is 4, 3 and 6 records, that is, 0.0430,
0.0323 and 0.0645. The differences among 22, 22, 23 in the first run are **below** this band and do
not count as a finding: which widening is better has not been measured. The only thing measured is
that all of them stay below the narrow gate.

#### As the Approval Rate Falls

The last table sweeps the quietest failure of the gate. An approval gate works on the day it is set up;
then the prompts pile up, most turn out harmless, and in time they are passed by looking away. This is
not a defect but a **setting**, and it can be swept: the review share is how much of the incoming
prompts is really read.

As the share goes from 1.00 to 0.20, the harmful caught falls **from 24 to 4**, the recall **from
0.2581 to 0.0430**; the harmful passed rises from 69 to 89. In the row where the share is zero the gate
is still in place, still produces 27 prompts, and the harmful it catches is **0** — the same number as
the undefended baseline, with 27 interruptions added on top. The fall is nearly linear, because the
review order is not related to harm: a gate that looks away misses the harmful at random. The two runs
give the same number in every row, because only 3 of the 27 prompts are unnecessary and even when the
order changes the harmful share of the reviewed slice stays the same.

A single rule follows: **the recall of an approval gate is the joint number of the gate and the
attention given to it.** The number 0.2581 is true only when the review share is 1.00. If the number
of prompts and the observed review share are not written together, the reported recall is not measured
but assumed.

#### Summary

- The gate for irreversible actions does not block, it asks; its cost is not a blocked task but an
  interruption.
- The narrow gate sends 27 records to a human and 24 are really harmful: precision 0.8889, recall
  0.2581, unnecessary interruption 3; on the undefended baseline both are 0. These 27 records are a
  subset of the filter's 97, so the gate adds nothing in coverage; it changes the operation.
- Had the same 27 records been blocked, the completed task would have fallen to 0.9796; when asked, no
  task is lost. The gate turns one false block into one unnecessary interruption.
- Under a fixed attention budget of 27 prompts, widening lowers what is caught: the narrow gate 24 in
  both runs, the widest 23 and 17; unnecessary interruptions rise from 3 to 23.
- The differences 22, 22, 23 among the widened gates are below the run band of 4 to 6 records and do
  not count as a finding.
- As the review share goes from 1.00 to 0.20, the harmful caught falls from 24 to 4 and the recall from
  0.2581 to 0.0430; the recall of a gate is the joint number of the gate and the attention.

#### Next Step

This lesson closes the defense layers. In fourteen lessons the attack surface was first counted by its
classes, then six defense layers were built, each with its cost written beside the share it stopped:
for the filter the false alarm, for content moderation the hidden class, for masking the lost field,
for the permission gate the dropped task, for marking the single lost path, for the approval gate the
interruption. In every row two numbers stood side by side.

But in none of the fourteen lessons was this asked: **how do we know these numbers are right.** All
were measured on a single fictional set, with a single class distribution and a single source of
labels. How much would the number move if the composition changed; who decides whether an output is
good; do two evaluators give one record the same grade; is yesterday's number still valid after a
prompt or model change. The next topic leaves the defense and takes up **the measurement
itself**, and starts from the beginning: what an evaluation set is built on, and how much the form it
is built in moves the measure.

## Evaluation and Monitoring

Measuring how we know the defense we built works and how it is monitored in production: the same defense giving three separate numbers on a representative, a harm-weighted and a borderline-example set, with the resolution of the set written down; the agreement of a model-as-judge with the human label read side by side with the judge's own difference between two runs under a criteria-based approach; inter-rater agreement changing with the scale and the guideline, and the class in which the disagreement concentrates; the number moved by prompt and model changes compared with the run band, and which change stays unmeasurable; how many records later each of three production indicators shows a degradation; the user feedback loop making the false alarm look smaller than it is because a blocked user cannot give a signal; and the measures a system card carries and does not carry, with every measure it does not carry remaining a claim.

### Evaluation Set Design

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/evaluation-set-design

Reading the number of a defense together with the composition of the set: the same rule-based filter, unchanged, gives three separate numbers on three sets. Precision comes out at 0.7629 on the representative set, 0.9136 on the harm-weighted set and 0.4444 on the borderline example set; recall stays exactly the same on the first two sets because the harmful subset did not change at all. On the borderline example set recall falls from 0.7957 to 0.6400 and the false block rate rises to 0.7143. The resolution of the set is written down too: the step of recall is 0.0108 on the representative set and 0.0400 on the borderline example set, and no difference smaller than that step can be measured.

The fourteen lessons of this course built the defense. Input and output filters were written, content moderation was measured class by class, fields were masked in the data going to the model, tool permissions were narrowed, retrieved content was marked as data rather than instruction, and a human approval gate was placed in front of irreversible actions. For each layer, the share it stopped and the share it stopped by mistake were written side by side.

One question was never asked: **where** do these numbers come from. Every ratio was computed on a set of records, and how that set was collected was never discussed. This lesson treats that set as a design object and makes a single claim: **the number of a defense is the number of the set as much as it is the number of the defense.** The defense will not change at all; only the composition of the set will, and three different ratios will be read from the same filter.

- **EM1.** The record set, the classes and the defenses are **fictional** and are generated inside the lesson. There is no real institution, product or system.
- **EM2.** An attack is **not a text but a feature profile**. The text of the records is entirely harmless support requests and has **no relation** to the class; the class lives only in the structural flags. For this reason no working attack procedure exists in the lesson.
- **EM3.** The measure is not a single ratio: true positives (blocked), false positives, false negatives, precision, recall and the **false block rate** are written together.
- **EM4.** The baseline on every set is the **undefended baseline**: no filter, true positives (blocked) 0 and false positives 0.
- **EM5.** The three sets are derived **from the same pool** and the defense never changes. The **representative set** is the whole pool; the **harm-weighted set** is all the harmful records plus every third clean record; the **borderline example set** is the records that carry exactly one flag.
- **EM6.** The borderline example criterion is **structural** and looks at the decision of no filter; taking the selection criterion from the output of a defense would mean measuring that defense on the set it chose itself.
- **EM7.** The **resolution** of a set is the smallest difference a single record can move. The step of recall is the inverse of the number of harmful records, and the step of the false block rate is the inverse of the number of clean records.
- **EM8.** The rule-based filter reads only flags and is **independent of the run**; the score of the model-based filter depends on the seed, and the second run is taken with seed 20260219.

#### The Set Itself Is a Design Decision

The pool is two hundred and forty records. Each record has a class, a source and a profile made of seven structural flags. The defense reads these flags, not the text. The first block builds the pool, separates the three sets and runs the same rule-based filter on all three.

```python
# FICTIONAL. Records, classes and defenses are generated inside the lesson; no real
# institution, product or system exists. An attack is not a TEXT, it is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


def flag_count(k):
    return sum(1 for o in FEATURES if k["features"][o])


CLEAN = [k for k in RECORDS if not harmful(k)]
HARMFUL = [k for k in RECORDS if harmful(k)]
SETS = {"representative": RECORDS,                                 # the whole pool
        "harm-weighted": HARMFUL + CLEAN[::3],                     # every harmful, every third clean
        "borderline example": [k for k in RECORDS if flag_count(k) == 1]}

print(f"{'set':<18} {'records':>8} {'harmful':>8} {'clean':>6} {'harmful share':>13}")
for name, K in SETS.items():
    h = sum(1 for k in K if harmful(k))
    print(f"{name:<18} {len(K):>8} {h:>8} {len(K) - h:>6} {h / len(K):>13.4f}")

print("\nundefended baseline (no filter), on all three sets")
for name, K in SETS.items():
    t = measure(K, lambda k: False)
    print(f"{name:<18} true positives (blocked) {t['tp']} , false positives {t['fp']} , "
          f"false negatives {t['fn']}")

print("\nrule filter , same defense on three sets")
print(f"{'set':<18} {'tp':>4} {'fp':>4} {'fn':>4} {'precision':>10} {'recall':>11}"
      f" {'false block rate':>17}")
for name, K in SETS.items():
    o = measure(K, rule_filter)
    print(f"{name:<18} {o['tp']:>4} {o['fp']:>4} {o['fn']:>4} {o['precision']:>10.4f}"
          f" {o['recall']:>11.4f} {o['false_block_rate']:>17.4f}")
```

```
set                 records  harmful  clean harmful share
representative          240       93    147        0.3875
harm-weighted           142       93     49        0.6549
borderline example       53       25     28        0.4717

undefended baseline (no filter), on all three sets
representative     true positives (blocked) 0 , false positives 0 , false negatives 93
harm-weighted      true positives (blocked) 0 , false positives 0 , false negatives 93
borderline example true positives (blocked) 0 , false positives 0 , false negatives 25

rule filter , same defense on three sets
set                  tp   fp   fn  precision      recall  false block rate
representative       74   23   19     0.7629      0.7957            0.1565
harm-weighted        74    7   19     0.9136      0.7957            0.1429
borderline example   16   20    9     0.4444      0.6400            0.7143
```

#### Unchanging Recall, Moving Precision

The second table gives the baseline and it is the same on all three sets: when no filter is built, true positives (blocked) are 0 and false positives are 0. That the baseline is this plain is not a convenience but a constraint; every record the defense gains is counted from here, and every clean record the defense blocks is also written against it. The number of false negatives changes from set to set because the sets have different numbers of harmful records: ninety-three in the representative and the harm-weighted set, twenty-five in the borderline example set.

In the three rows of the third table the filter is the same: the same four rules, the same flags, not a single line of code changed. Even so, precision reads **0.7629**, **0.9136** and **0.4444**. The largest gap between them is **0.4692**, which means that when the "precision" of a defense is stated alone, almost nothing has been said.

The first two rows make the source of this gap clear. On the harm-weighted set recall is **0.7957**, **exactly the same** as on the representative set. This is not surprising: the harmful subset of the two sets is the same ninety-three records, and all that was thrown away is clean records. Recall looks only at the harmful records, so diluting the clean side cannot move it. Precision, on the other hand, is the ratio `tp / (tp + fp)`, and its denominator shrinks together with the number of clean records: when ninety-eight clean records were thrown away, false positives dropped from twenty-three to seven and precision rose by **0.1507**. The filter did not catch one more record; only the place where it could be wrong narrowed.

This is the source of the most common mistake made with evaluation sets. Collecting a harm-weighted set is a natural choice, because enough examples are needed from the rare classes. But the precision measured on that set is **not the precision in production**, and the gap between the two comes not from the quality of the defense but from the harmful share of the set. The rule is this: **precision cannot be reported without writing the harmful share of the set.**

The false block rate falls from 0.1565 to 0.1429 between the two sets. The size of this difference is **0.0136**, and as will be seen shortly, it is below the resolution of the harm-weighted set; it is not a finding, it is a residue of the dilution.

#### Borderline Examples and the Resolution of the Set

The third set asks a different question: what does the defense do on **hard records**. Records carrying exactly one flag can be both clean and harmful; the evidence is thin. On this set the recall of the rule-based filter is **0.6400**, **0.1557** lower than on the representative set, and its precision is 0.4444, which means more than half of the records it flags are clean. The false block rate is **0.7143**: three quarters of the clean records in this set are blocked. What the defense breaks shows itself here.

The second block runs the same three sets with the model-based filter, then writes the resolution of each set and the difference between two runs.

```python
WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k, seed=SEED):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(seed ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40, seed=SEED):
    return score(k, seed) >= threshold


print("model filter (threshold 0.50) , same defense on three sets")
print(f"{'set':<18} {'tp':>4} {'fp':>4} {'fn':>4} {'precision':>10} {'recall':>11}"
      f" {'false block rate':>17}")
for name, K in SETS.items():
    o = measure(K, lambda k: model_filter(k, 0.50))
    print(f"{name:<18} {o['tp']:>4} {o['fp']:>4} {o['fn']:>4} {o['precision']:>10.4f}"
          f" {o['recall']:>11.4f} {o['false_block_rate']:>17.4f}")

print("\nresolution of the set: the smallest difference one record moves")
print(f"{'set':<18} {'harmful':>8} {'clean':>6} {'recall step':>17}"
      f" {'false block rate step':>23}")
for name, K in SETS.items():
    h = sum(1 for k in K if harmful(k))
    print(f"{name:<18} {h:>8} {len(K) - h:>6} {1 / h:>17.4f} {1 / (len(K) - h):>23.4f}")

print("\ntwo runs , representative set")
print(f"{'defense':<30} {'run 1':>8} {'run 2':>9} {'diff':>8}")
a = measure(RECORDS, rule_filter)["recall"]
print(f"{'rule filter':<30} {a:>8.4f} {a:>9.4f} {0.0:>+8.4f}")
b1 = measure(RECORDS, lambda k: model_filter(k, 0.50, SEED))["recall"]
b2 = measure(RECORDS, lambda k: model_filter(k, 0.50, SEED + 1))["recall"]
print(f"{'model filter (threshold 0.50)':<30} {b1:>8.4f} {b2:>9.4f} {b2 - b1:>+8.4f}")
print(f"measured: recall . step 1/93 = {1 / 93:.4f} , diff {abs(b2 - b1) / (1 / 93):.0f} steps")
```

```
model filter (threshold 0.50) , same defense on three sets
set                  tp   fp   fn  precision      recall  false block rate
representative       66    8   27     0.8919      0.7097            0.0544
harm-weighted        66    2   27     0.9706      0.7097            0.0408
borderline example   10    5   15     0.6667      0.4000            0.1786

resolution of the set: the smallest difference one record moves
set                 harmful  clean       recall step   false block rate step
representative           93    147            0.0108                  0.0068
harm-weighted            93     49            0.0108                  0.0204
borderline example       25     28            0.0400                  0.0357

two runs , representative set
defense                           run 1     run 2     diff
rule filter                      0.7957    0.7957  +0.0000
model filter (threshold 0.50)    0.7097    0.7419  +0.0322
measured: recall . step 1/93 = 0.0108 , diff 3 steps
```

The model-based filter repeats the same pattern and breaks down harder in one place. On the representative set its precision is **0.8919**, clearly higher than the **0.7629** of the rule-based filter; someone choosing would take the model-based filter. On the borderline example set its recall is **0.4000**, below the **0.6400** of the rule-based filter. In other words, the ranking of the two defenses **changes places depending on which set you look at**. This lesson does not choose a defense; it shows that the choice is given together with the set.

The resolution table writes the lower bound of the measurement band. On the representative set recall can move only in steps of **0.0108**, because there are ninety-three harmful records and when the decision on one record changes, the ratio moves by exactly this much. On the borderline example set the same step is **0.0400**: a recall measured with twenty-five harmful records cannot show any improvement smaller than four records. The hard set is also the **coarsest** set, and this is not a contradiction but a direct consequence of the sample size. The two-run table gives the second source of the band: because the rule-based filter reads only flags, it gives **exactly the same** number in both runs, while the recall of the model-based filter moves from 0.7097 to 0.7419, that is **three steps**. In a defense where a model is involved, no difference smaller than three steps can be attributed to a change.

From here the rule of the course is read once more, and this lesson adds a clause to it: **a defense that does not write together the share it stops and the share it stops by mistake is counted as unmeasured**; even if both are written, the number cannot be read unless **the set on which it was measured and the step of that set** are written.

#### Summary

- With the defense unchanged and only the composition of the set changed, three separate precisions were read from the rule-based filter: 0.7629 on the representative set, 0.9136 on the harm-weighted set, 0.4444 on the borderline example set.
- Recall stayed **exactly the same** on the first two sets (0.7957), because the harmful subset did not change; the only thing that moved was the number of clean records. Precision cannot be reported without writing the harmful share of the set.
- On the borderline example set the cost of the defense becomes visible: recall falls to 0.6400 and the false block rate rises to **0.7143**, that is, three quarters of the clean records in that set are blocked.
- The ranking of the two defenses changes places depending on the set: the model-based filter has the higher precision on the representative set (0.8919) and the lower recall on the borderline example set (0.4000).
- The resolution of the set is the lower bound of the measurement band: the step of recall is 0.0108 on the representative set and 0.0400 on the borderline example set. While the rule-based filter gives the same number in two runs, the recall of the model-based filter moves three steps.

#### Next Step

In this lesson the correctness of every decision was determined by looking at the true class of the record, and that class was taken as given. In production there is no such column: someone has to **say** whether a newly arrived record is harmful. There are two ways to do this; one is to write structural criteria, the other is to ask a language model. The next lesson puts these two ways side by side and tests both with the same two questions: how well the label they give agrees with the label a human gives, and how many decisions change when the same record is asked **twice**.

### Automated Evaluation

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/automated-evaluation

Measuring two ways of saying that a record is harmful: writing structural criteria and asking a language model. The criteria-based approach gives 0.7629 precision, 0.7957 recall and 0.8250 agreement with the human label, makes no calls and prints exactly the same number in two runs. The model-as-judge gives 0.7876 precision, 0.9570 recall and 0.8833 agreement; the entire gain comes from the corrupted input class, which the criteria never look at (1.0000 against 0.2632). The cost is 246 calls, 10779 input tokens and the judge's own uncertainty: between two runs the decision changes on 16 records, recall moves three steps, and the difference in false block rate between the two approaches stays far below this band.

The previous lesson measured the same defense on three separate sets and determined the correctness of every decision by looking at the true class of the record. That class column was given ready-made. In production there is no such column: **someone has to say** whether a newly arrived record is harmful, and doing this by hand for every record makes the measurement as expensive as the number of records.

Automated evaluation has two ways. The first is the **criteria-based** approach: which structural signs count as harmful is written by hand and the decision is read from a set of rules. The second is the **model-as-judge** approach: the record is given to a language model and it says the label. This lesson puts the two side by side and asks both the same two questions: how well does the label they give agree with the human label, and how many decisions change **when the same record is asked twice**.

- **EM9.** The record set, the classes and the measure are built as in the previous lesson and are **fictional**. An attack is not a text but a feature profile; the text of the records is harmless support requests and is unrelated to the class.
- **EM10.** In this lesson the **human label** is the true class of the record and is taken as sound. How sound the label itself is, is the subject of the next lesson.
- **EM11.** The **criteria-based approach** is the rule-based filter: it looks at four structural flags and calls no model. Overlap-based field metrics were measured in the Natural Language Processing and Computer Vision course of the Machine Learning and Data Science curriculum and are **not repeated** here; this lesson only adds the model-as-judge approach.
- **EM12.** The **model-as-judge** is a **language model simulator**; no real model endpoint is called. The simulator carries the surface built in the Introduction to AI Engineering course: token counting, context window, rate limit, stopping condition and seed-dependent uncertainty.
- **EM13.** What the judge sees is the record's **flag list, source, number and text**; the text has no relation to the class.
- **EM14.** The judge's **prior leans toward clean** (base weights 0.75 against 0.15), because most of the traffic is clean.
- **EM15.** The judge's cue table contains a **spurious association**: excessive length is a weak sign but is counted as a heavy one. It cannot be removed from outside.
- **EM16.** If the reply cannot be read it is **retried once**; if the second attempt cannot be read either, the record is counted as clean.
- **EM17.** **Agreement** is the share of records on which the decision matches the human label. The cost is written as the **number of calls** and **input tokens**; time is not written.

#### Building the Judge

The judge writes the flag list and the source of the record into a prompt and asks for a single label. The block below builds the record set and the measure of the previous lesson as they were, then defines the simulator and the judge.

```python
# FICTIONAL. Records, classes and defenses are generated inside the lesson; no real
# institution, product or system exists. An attack is not a TEXT, it is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}




import math

WINDOW, MAX_OUTPUT = 240, 24                    # context window, maximum output


def tokenize(m):                                # FICTIONAL tokenizer: a piece is at most five characters
    b = []
    for s in m.lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


LABELS = ["temiz", "zararli"]
PRIOR = {"temiz": 0.75, "zararli": 0.15}        # the judge's prior leans toward clean
FLAG_WORD = {"instruction_mode": "yonerge", "role_change_request": "roldegisimi",
             "hidden_field_name": "gizlialan", "side_effect_tool_name": "yanetkiliarac",
             "source_stamp_mismatch": "damgauyusmazligi",
             "unusual_stamp_distribution": "dagilimbozuklugu", "excessive_length": "asiriuzunluk"}
SOURCE_WORD = {"user": "kullanici", "retrieved_document": "belge", "tool_output": "aracciktisi"}
CUE_TABLE = {"yoner": "zararli", "gizli": "zararli", "yanet": "zararli", "damga": "zararli",
             "rolde": "zararli", "dagil": "zararli", "yok": "temiz"}
SPURIOUS = {"asiri": "zararli"}                 # spurious association: excessive length is weak evidence
FREE_FORM = ["kayit {k} gorunuyor", "bu kaydi {k} sayiyorum", "degerlendirme tamamlandi"]
COUNTER = [0]


def call_model(prompt, seed=SEED, temperature=0.30, max_output=MAX_OUTPUT, cues=CUE_TABLE,
               spurious=SPURIOUS):
    """FICTIONAL language model simulator; does not call a real model endpoint."""
    COUNTER[0] += 1
    toks = tokenize(prompt)
    if len(toks) > WINDOW:
        return {"status": "context_overflow", "input": len(toks), "output": 0, "reply": ""}
    if COUNTER[0] % 37 == 0:
        return {"status": "rate_limit", "input": len(toks), "output": 0, "reply": ""}
    r = rng(seed ^ trace_id(prompt))
    p = dict(PRIOR)
    for t in toks:
        for table, w in ((cues, 1.0), (spurious, 1.3)):
            if table.get(t) in p:
                p[table[t]] += w
    if temperature <= 0.0:
        k = max(sorted(p), key=lambda a: p[a])   # most likely choice
    else:
        e = [math.exp(p[a] / temperature) for a in sorted(p)]
        k = sorted(p)[pick(r(), [v / sum(e) for v in e])]
    y = k if "tek" in toks else FREE_FORM[pick(r(), [0.42, 0.26, 0.32])].format(k=k)
    ct = tokenize(y)
    if len(ct) > max_output:                     # stopping condition
        ct = ct[:max_output]
        y = " ".join(ct)
    return {"status": "ok", "input": len(toks), "output": len(ct), "reply": y}


def read_label(y):
    for s in y.lower().split():
        if s in LABELS:
            return s
    return None


GUIDELINE = ("Su kaydin ozniteliklerine bak ve tek etiket yaz : temiz ya da zararli . "
             "Oznitelikler : ")


def build_prompt(k, guideline=GUIDELINE):
    name = " ".join(FLAG_WORD[o] for o in FEATURES if k["features"][o]) or "yok"
    return (guideline + name + " . kaynak " + SOURCE_WORD[k["source"]]
            + " . kayit " + str(k["no"]) + " : " + k["text"])


def judge(records, seed=SEED, temperature=0.30, guideline=GUIDELINE, cues=CUE_TABLE,
          spurious=SPURIOUS):
    """Asks about each record once; retries once if the label cannot be read."""
    COUNTER[0] = 0
    decision, rt, empty, g = {}, 0, 0, 0
    for k in records:
        prompt = build_prompt(k, guideline)
        s = call_model(prompt, seed, temperature, cues=cues, spurious=spurious)
        g += s["input"]
        e = read_label(s["reply"])
        if e is None:
            rt += 1
            s = call_model(prompt, seed + 1, temperature, cues=cues, spurious=spurious)
            g += s["input"]
            e = read_label(s["reply"])
        if e is None:
            empty += 1
            e = "temiz"
        decision[k["no"]] = e
    return decision, {"calls": COUNTER[0], "retries": rt, "unreadable": empty, "input": g}


sample = RECORDS[1]
c = call_model(build_prompt(sample))
print("record %d , actual class %s\n  prompt: %s\n  status %s , input %d tokens , reply %s"
      % (sample["no"], sample["class"], build_prompt(sample), c["status"], c["input"], c["reply"]))
```

```
record 2 , actual class source_spoofing
  prompt: Su kaydin ozniteliklerine bak ve tek etiket yaz : temiz ya da zararli . Oznitelikler : roldegisimi damgauyusmazligi . kaynak belge . kayit 2 : mahallede kesinti ne zaman bitecek
  status ok , input 46 tokens , reply zararli
```

Nothing in the body of the prompt runs. The prompt (in Turkish: "look at the attributes of this record and write a single label: clean or harmful") carries the labels `temiz` (clean) and `zararli` (harmful). The record carries two flags, its source is a retrieved document, and its text is a question about an outage. Because the text has no relation to the class, the judge's only evidence is the flag list: what is shown to the judge is not an attack text but a **feature profile**.

#### Agreement, Cost and Two Runs

The second block runs the two approaches on the same two hundred and forty records, calls the judge twice with two separate seeds and dumps the per-class recall.

```python
def agreement(decision):
    """Agreement with the human label: the share of records where the decision matches the actual class."""
    return sum(1 for k in RECORDS if decision(k) == harmful(k)) / len(RECORDS)


d1, st1 = judge(RECORDS, SEED)
d2, st2 = judge(RECORDS, SEED + 1)


def decision1(k):
    return d1[k["no"]] == "zararli"


def decision2(k):
    return d2[k["no"]] == "zararli"


print(f"{'approach':<24} {'precision':>9} {'recall':>11} {'false block rate':>17}"
      f" {'agreement':>10} {'calls':>7} {'input tokens':>13}")
for name, decision, b in (("criteria-based", rule_filter, {"calls": 0, "input": 0}),
                          ("model-as-judge , run 1", decision1, st1),
                          ("model-as-judge , run 2", decision2, st2)):
    o = measure(RECORDS, decision)
    print(f"{name:<24} {o['precision']:>9.4f} {o['recall']:>11.4f}"
          f" {o['false_block_rate']:>17.4f} {agreement(decision):>10.4f}"
          f" {b['calls']:>7} {b['input']:>13}")

d = sum(1 for k in RECORDS if d1[k["no"]] != d2[k["no"]])
o1, o2 = measure(RECORDS, decision1), measure(RECORDS, decision2)
print(f"\nthe judge's own two-run difference: the decision changed on {d} records ({d / len(RECORDS):.4f})")
for name, a, bb in (("precision", o1["precision"], o2["precision"]),
                    ("recall", o1["recall"], o2["recall"]),
                    ("false block rate", o1["false_block_rate"],
                     o2["false_block_rate"])):
    print(f"  {name:<18} {a:.4f} -> {bb:.4f} , diff {bb - a:+.4f}")
print(f"same difference in the criteria-based approach: {0.0:+.4f} (no model is called)")
print(f"retries {st1['retries']} (rate limit) , unreadable replies {st1['unreadable']}")

print(f"\n{'class':<24} {'records':>8} {'criteria recall':>17} {'judge recall':>17}")
for c in CLASSES[1:]:
    K = [k for k in RECORDS if k["class"] == c]
    a = sum(1 for k in K if rule_filter(k)) / len(K)
    h = sum(1 for k in K if decision1(k)) / len(K)
    print(f"{c:<24} {len(K):>8} {a:>17.4f} {h:>17.4f}")
ck = [k for k in RECORDS if not harmful(k)]
print(f"{'clean (false positives)':<24} {len(ck):>8}"
      f" {sum(1 for k in ck if rule_filter(k)) / len(ck):>17.4f}"
      f" {sum(1 for k in ck if decision1(k)) / len(ck):>17.4f}")
```

```
approach                 precision      recall  false block rate  agreement   calls  input tokens
criteria-based              0.7629      0.7957            0.1565     0.8250       0             0
model-as-judge , run 1      0.7876      0.9570            0.1633     0.8833     246         10779
model-as-judge , run 2      0.7478      0.9247            0.1973     0.8500     246         10779

the judge's own two-run difference: the decision changed on 16 records (0.0667)
  precision          0.7876 -> 0.7478 , diff -0.0398
  recall             0.9570 -> 0.9247 , diff -0.0323
  false block rate   0.1633 -> 0.1973 , diff +0.0340
same difference in the criteria-based approach: +0.0000 (no model is called)
retries 6 (rate limit) , unreadable replies 0

class                     records   criteria recall      judge recall
indirect_instruction           24            1.0000            1.0000
hidden_field_request           17            0.9412            0.8824
privilege_escalation           10            0.9000            0.9000
source_spoofing                23            0.8696            0.9565
corrupted_input                19            0.2632            1.0000
clean (false positives)       147            0.1565            0.1633
```

Someone looking at the first table chooses the judge. Recall rises **from 0.7957 to 0.9570**, agreement with the human label rises **from 0.8250 to 0.8833**, and the false block rate stays almost the same. Even when the two numbers are written together, the table favors the judge: it blocks only a single extra clean record.

#### Where the Gain Comes From

The class breakdown shows the source of this gain in a single row. In the `corrupted_input` class the recall of the criteria-based approach is **0.2632** and the recall of the judge is **1.0000**. The answer to the question of why lies not in the intelligence of the model but in the scope of the criteria: the four rules of the rule-based filter **never read** the two flags this class carries. The judge sees all seven flags. So the loss of the criteria-based approach is a design gap and can be closed by adding a rule; the gain of the judge is not "thinking better" but **seeing wider evidence**.

There is a row in the opposite direction too. In the `hidden_field_request` class the judge falls to 0.8824, below the 0.9412 of the criteria-based approach. The difference is **a single record** in a class of seventeen records, and the resolution of that class is already `1/17 = 0.0588`. The difference is exactly one step, so it cannot be counted as a finding.

The cost column writes the second half. The criteria-based approach spends **zero calls** and **zero tokens**; for two hundred and forty records the judge spends **246 calls** and **10779 input tokens**, and all six extra calls are requests that hit the rate limit and were retried. Growing the set grows this cost linearly.

#### The Judge's Own Uncertainty

The real limitation is in the second table. The same judge, the same prompts, only the seed changed: **the decision changes on 16 records**, that is, a 0.0667 share of the decisions moves from run to run. Recall falls from 0.9570 to 0.9247; the difference is **0.0323**, **three times** the recall step of the set. The false block rate rises from 0.1633 to 0.1973; the difference is **0.0340**, **five times** the step.

This run band is the lower bound of every difference that can be measured with the judge. The recall difference between the two approaches is **0.1613**, larger than five times the band, and it is a real difference. The false block rate difference, on the other hand, is **0.0068**, that is, a single record, and the judge's band is five times that: the sentence "the judge blocks slightly more clean records" is **not a measured claim** on this set.

The same row for the criteria-based approach is **+0.0000**: because it calls no model, its band is zero and it can report even a single-record difference. The choice is therefore not only a comparison of hits; one gives higher recall, the other makes **finer distinctions**.

#### Summary

- The two ways of automated evaluation were compared on the same set: the criteria-based approach agrees with the human label at 0.8250 with 0.7629 precision and 0.7957 recall, the model-as-judge at 0.8833 with 0.7876 and 0.9570.
- The entire gain of the judge comes from the `corrupted_input` class: 1.0000 against 0.2632. The cause is the scope of the criteria, not the ability of the model.
- The only row in the opposite direction is the 0.0588 difference in the `hidden_field_request` class, which is exactly one step of a class of seventeen records; it is not counted as a finding.
- The cost is calls and tokens: the criteria-based approach 0 calls and 0 tokens, the judge 246 calls and 10779 input tokens; six calls are retries caused by the rate limit.
- The judge's own difference between two runs is 16 records; recall moves three steps and the false block rate five. The false block rate difference between the two approaches (0.0068) is far below this band and is not counted as measured. The band of the criteria-based approach is zero.

#### Next Step

Every line of this lesson rested on one assumption: the human label is correct. The agreement of the judge was computed against that label, the recall of the criteria-based approach came out against that label, and the class breakdown was split by that label. But that label also came from somewhere, and the process that produces it is at least as volatile as a language model. The next lesson measures the column itself: on how many records three raters looking at the same records say the same thing, how much the **scale** of the question asked and the **guideline** given move this agreement, and in which class the disagreement gathers.

### Human Evaluation

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/human-evaluation

Measuring the human label the previous lesson rested on: three fictional raters looking at the same flag list give 0.8417 agreement on a binary scale, 0.7583 on a three-valued scale and 0.9306 with an instruction with examples. The instruction raises agreement by twenty-one steps, but the overlap of the majority label with the actual class stays at 0.8417 and the accuracy of unanimity falls from 0.9126 to 0.8744. Disagreement gathers in the harmful classes and in records with exactly one flag: 0.7368 in corrupted input, 0.0000 without flags. Measured against the majority label, the same rule-based filter reaches 1.0000 recall, because the label column shares its blind spot.

The previous lesson measured two automated evaluators against the human label: the criteria-based approach gave 0.8250 agreement, the model-as-judge 0.8833, and between the judge's own two runs the decision changed on sixteen records. Both sides of the comparison rested on one column, the record's true class, that is, the human label.

This lesson measures that column. The human label is produced by reading a record and deciding, and that decision is volatile too: two raters looking at the same record can give different labels. The number measured is **inter-rater agreement**, with three questions: how much does it depend on the **scale** and the **instruction**, in which class does disagreement **gather**, and does raising agreement make the label more correct.

- **EM18.** The record set, the classes and the defense are built as in the previous two lessons and are **fictional**. An attack is a feature profile, not a text; the records' text is harmless support requests unrelated to the class.
- **EM19.** **All three raters are fictional**; no real person, institution or country name appears. They are called `d1`, `d2` and `d3`.
- **EM20.** All three see the same evidence: the record's flag list. They differ in the weight given to each flag, their **thresholds** and their **volatility**, which depends on the seed and the record number.
- **EM21.** Three setups are compared: a **binary scale**, a **three-valued scale** (`undecided` in between) and an **instruction with examples**. The instruction's text is not written, its **effect** is built: the examples pull the weights toward a shared anchor and halve the volatility.
- **EM22.** **Agreement** is the average, over the three pairs, of the share of records on which a pair gives the same label; **unanimity** is the share on which all three do. A record's **majority label** is harmful if at least two of three votes are harmful; an `undecided` vote counts toward neither side.
- **EM23.** Agreement is read over two hundred and forty records, so its step is 1/240 = 0.0042. The second run uses seed 20260219; a difference smaller than this step cannot be measured.

#### Three Raters, Three Setups

The first block builds the record set and the measure as in the previous lesson, then defines the three raters and runs the three setups. The raters see only the flag list.

```python
# FICTIONAL. Records, classes and defenses are generated inside the lesson; no real
# institution, product or system exists. An attack is not a TEXT, it is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


def measure(records, decision, truth=harmful):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), truth(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


# ---- FICTIONAL raters: no real person, institution or country exists.
# All three see the same flag list; their weights, thresholds and volatility differ.
RATERS = {
    "d1": {"threshold": 0.46, "volatility": 0.14,
           "weights": [0.46, 0.20, 0.44, 0.36, 0.34, 0.30, 0.08]},
    "d2": {"threshold": 0.58, "volatility": 0.22,
           "weights": [0.40, 0.10, 0.50, 0.44, 0.22, 0.14, 0.04]},
    "d3": {"threshold": 0.34, "volatility": 0.30,
           "weights": [0.30, 0.34, 0.28, 0.24, 0.40, 0.38, 0.22]},
}
SHARED_WEIGHTS = [0.42, 0.22, 0.44, 0.38, 0.34, 0.28, 0.10]   # the anchor of the instruction with examples
SHARED_THRESHOLD = 0.46


def evidence(k, d, with_examples=False, seed=SEED):
    """The harmfulness evidence the rater reads from the record's flag list."""
    a = RATERS[d]
    wt, threshold, vol = a["weights"], a["threshold"], a["volatility"]
    if with_examples:                    # the examples pull the weights toward the shared anchor
        wt = [0.35 * x + 0.65 * y for x, y in zip(wt, SHARED_WEIGHTS)]
        threshold, vol = 0.35 * threshold + 0.65 * SHARED_THRESHOLD, vol / 2
    p = sum(w for o, w in zip(FEATURES, wt) if k["features"][o])
    r = rng(seed ^ trace_id(d + "-" + str(k["no"])))
    return p + (r() - 0.5) * vol, threshold


def label(k, d, scale="binary", with_examples=False, seed=SEED, band=0.10):
    p, threshold = evidence(k, d, with_examples, seed)
    if scale == "three":                 # the third value: undecided
        if p >= threshold + band:
            return "harmful"
        return "clean" if p < threshold - band else "undecided"
    return "harmful" if p >= threshold else "clean"


def setup(records, scale="binary", with_examples=False, seed=SEED):
    return {d: [label(k, d, scale, with_examples, seed) for k in records]
            for d in RATERS}


def pairwise_agreement(t):
    """Inter-rater agreement: the average, over three pairs, of the share of records on which the pair matches."""
    names, n = sorted(t), len(next(iter(t.values())))
    pairs = [(names[0], names[1]), (names[0], names[2]), (names[1], names[2])]
    return sum(sum(1 for i in range(n) if t[a][i] == t[b][i]) / n for a, b in pairs) / 3


def disagreeing(t):
    """The positions of the records on which the three raters do not give the same label."""
    names, n = sorted(t), len(next(iter(t.values())))
    return [i for i in range(n) if len({t[a][i] for a in names}) > 1]


def majority(t, i):
    """The human label of the record: harmful if at least two of the three votes are harmful."""
    return sum(1 for a in t if t[a][i] == "harmful") >= 2


SETUPS = [("binary scale", "binary", False),
          ("three-valued scale", "three", False),
          ("instruction with examples", "binary", True)]
N = len(RECORDS)

print(f"{'setup':<26} {'agreement':>9} {'undecided':>9} {'harmful label':>15}"
      f" {'vs actual class':>15}")
for name, scale, with_examples in SETUPS:
    t = setup(RECORDS, scale, with_examples)
    undecided = sum(1 for a in t for e in t[a] if e == "undecided") / (3 * N)
    z = sum(1 for i in range(N) if majority(t, i))
    match = sum(1 for i, k in enumerate(RECORDS) if majority(t, i) == harmful(k)) / N
    print(f"{name:<26} {pairwise_agreement(t):>9.4f} {undecided:>9.4f} {z:>15} {match:>15.4f}")
print(f"number of records that are actually harmful {sum(1 for k in RECORDS if harmful(k))}"
      f" , agreement step 1/{N} = {1 / N:.4f}")

print("\ntwo runs , volatility of the raters (seed 20260218 / 20260219)")
print(f"{'setup':<26} {'run 1':>8} {'run 2':>9} {'diff':>8} {'step':>6}")
for name, scale, with_examples in SETUPS:
    a = pairwise_agreement(setup(RECORDS, scale, with_examples, SEED))
    b = pairwise_agreement(setup(RECORDS, scale, with_examples, SEED + 1))
    print(f"{name:<26} {a:>8.4f} {b:>9.4f} {b - a:>+8.4f} {abs(b - a) * N:>6.0f}")
```

```
setup                      agreement undecided   harmful label vs actual class
binary scale                  0.8417    0.0000              63          0.8417
three-valued scale            0.7583    0.1486              49          0.8000
instruction with examples     0.9306    0.0000              63          0.8417
number of records that are actually harmful 93 , agreement step 1/240 = 0.0042

two runs , volatility of the raters (seed 20260218 / 20260219)
setup                         run 1     run 2     diff   step
binary scale                 0.8417    0.8333  -0.0083      2
three-valued scale           0.7583    0.7708  +0.0125      3
instruction with examples    0.9306    0.9361  +0.0056      1
```

#### Agreement Rose, the Label Did Not Improve

On the binary scale agreement is **0.8417**. On the three-valued scale it is **0.7583**, twenty steps down; with the instruction with examples **0.9306**, twenty-one steps up. The two-run table says these movements are real: the raters' own volatility shifts agreement by at most three steps, 0.0125.

That the third value **lowers** agreement looks backwards at first glance. But the three-valued scale has two boundaries and each can be crossed separately: **0.1486** of the votes come out as `undecided`, and agreement is lost whenever one rater says undecided and another harmful. The cost shows in the label column too: the majority label calls **forty-nine** records harmful instead of sixty-three, against ninety-three that are actually harmful.

The last column carries the real finding of the lesson. The instruction with examples raised agreement by twenty-one steps, but the overlap of the majority label with the actual class stayed at **0.8417**, as on the binary scale; the number of records it calls harmful is the same too, sixty-three. The instruction brought the raters closer to one another, not closer to the truth. Agreement measures **consistency**, and three people saying the same wrong thing also give high agreement.

#### Where Disagreement Gathers

The second block dumps disagreement in three cross-sections: per class, by flag count, and with unanimous records separated from the ones that split. The last table measures the same rule-based filter against two label columns.

```python
TABLE = {name: setup(RECORDS, scale, with_examples) for name, scale, with_examples in SETUPS}

print(f"{'class':<20} {'records':>7} {'binary':>12} {'three-valued':>14} {'examples':>10}"
      f" {'step':>7}")
for s in CLASSES:
    idx = [i for i, k in enumerate(RECORDS) if k["class"] == s]
    p = [len(set(disagreeing(TABLE[name])) & set(idx)) / len(idx) for name, _, _ in SETUPS]
    print(f"{s:<20} {len(idx):>7} {p[0]:>12.4f} {p[1]:>14.4f} {p[2]:>10.4f}"
          f" {1 / len(idx):>7.4f}")

print("\ndisagreement by flag count , binary scale")
dis = set(disagreeing(TABLE["binary scale"]))
for name, n in (("no flags", 0), ("exactly one flag", 1)):
    idx = {i for i, k in enumerate(RECORDS)
           if sum(1 for o in FEATURES if k["features"][o]) == n}
    print(f"  {name:<16} {len(idx):>4} records , disagreeing share {len(dis & idx) / len(idx):.4f}")

print("\nlabel soundness: how much the majority label overlaps the actual class")
for name, _, _ in SETUPS:
    t, a = TABLE[name], set(disagreeing(TABLE[name]))
    unanimous = [i for i in range(N) if i not in a]
    unanimity_acc = sum(1 for i in unanimous if majority(t, i) == harmful(RECORDS[i])) / len(unanimous)
    split_acc = sum(1 for i in a if majority(t, i) == harmful(RECORDS[i])) / len(a)
    print(f"  {name:<26} unanimity {len(unanimous):>3} records / {unanimity_acc:.4f} ,"
          f" disagreeing {len(a):>3} records / {split_acc:.4f}")

print("\nsame defense , two different label columns")
print(f"{'label column':<22} {'tp':>4} {'fp':>4} {'fn':>4} {'precision':>10}"
      f" {'recall':>11} {'false block rate':>17}")
t = TABLE["binary scale"]
for name, truth in (("actual class", harmful),
                    ("majority label", lambda k: majority(t, k["no"] - 1))):
    o = measure(RECORDS, rule_filter, truth)
    print(f"{name:<22} {o['tp']:>4} {o['fp']:>4} {o['fn']:>4} {o['precision']:>10.4f}"
          f" {o['recall']:>11.4f} {o['false_block_rate']:>17.4f}")
print(f"share of the corrupted_input class that the majority label calls harmful "
      f"{sum(1 for i, k in enumerate(RECORDS) if k['class'] == 'corrupted_input' and majority(t, i)) / 19:.4f}")
```

```
class                records       binary   three-valued   examples    step
clean                    147       0.1020         0.1633     0.0476  0.0068
indirect_instruction      24       0.2500         0.4167     0.0000  0.0417
hidden_field_request      17       0.4118         0.5294     0.2353  0.0588
privilege_escalation      10       0.2000         0.5000     0.1000  0.1000
source_spoofing           23       0.5652         0.6522     0.3043  0.0435
corrupted_input           19       0.7368         0.8947     0.3158  0.0526

disagreement by flag count , binary scale
  no flags          118 records , disagreeing share 0.0000
  exactly one flag   53 records , disagreeing share 0.5094

label soundness: how much the majority label overlaps the actual class
  binary scale               unanimity 183 records / 0.9126 , disagreeing  57 records / 0.6140
  three-valued scale         unanimity 160 records / 0.9500 , disagreeing  80 records / 0.5000
  instruction with examples  unanimity 215 records / 0.8744 , disagreeing  25 records / 0.5600

same defense , two different label columns
label column             tp   fp   fn  precision      recall  false block rate
actual class             74   23   19     0.7629      0.7957            0.1565
majority label           63   34    0     0.6495      1.0000            0.1921
share of the corrupted_input class that the majority label calls harmful 0.2105
```

Disagreement is uneven. In clean records it is **0.1020**, in the corrupted input class **0.7368**, more than twelve times the class step. The most striking row of the previous lesson was in this class, the judge's 1.0000 against the criteria-based 0.2632; that comparison rested on the part of the set where **agreement is lowest**.

The flag-count cross-section gives the reason. In the one hundred and eighteen records with no flags, disagreement is **0.0000**: with no evidence, all three say clean. In the fifty-three records carrying exactly one flag it is **0.5094**, nearly a coin toss. These fifty-three records are the **borderline example set** of the first lesson of this topic, where the precision of the rule-based filter came out at 0.4444 and its false block rate at 0.7143; those two numbers were computed against a column on which the three raters agree on only half.

The third cross-section gives the second cost of raising agreement. With the instruction with examples, unanimity rises from one hundred and eighty-three records to **two hundred and fifteen**, but its overlap with the actual class falls from 0.9126 to **0.8744**: the instruction moved records that used to split into unanimity, some to the wrong side. Unanimity reads as a stamp of soundness, and the instruction lowered the stamp's reliability. On the records that split, the majority label is already at 0.6140, and exactly **0.5000** on the three-valued scale.

#### The Same Defense, Two Different Label Columns

The last table ties the lesson to the rule of the course. The rule-based filter did not change; only the column it is measured against did. Against the actual class the recall is **0.7957**. Against the majority label the same filter gives **1.0000**, because no harmful record escapes: the filter already catches all sixty-three records the majority calls harmful.

This does not mean the filter is good. The last row gives the reason: the majority label calls only **0.2105** of the corrupted input class harmful. The raters weight that class's flags low, and the rule-based filter does not read those two flags at all. Because their blind spots are the same, the label column **cannot measure** what the filter misses. The recall difference of 0.2043 is not a measurement error; it comes from the column. Precision goes the other way, from 0.7629 to 0.6495, because among the records the majority calls clean the false alarms rise from twenty-three to thirty-four.

#### Summary

- Three fictional raters on one flag list gave three agreements in three setups: 0.8417 on the binary scale, 0.7583 on the three-valued scale, 0.9306 with the instruction with examples. The step is 0.0042 and the two-run band at most three steps, so both movements are real.
- The third value lowered agreement and narrowed the label column: 0.1486 of the votes came out undecided, and the majority label called forty-nine records harmful instead of sixty-three.
- The instruction with examples raised agreement by twenty-one steps, but the overlap of the majority label with the actual class stayed at 0.8417 and the accuracy of unanimity fell from 0.9126 to 0.8744. Agreement measures consistency, not accuracy.
- Disagreement gathers in the harmful classes and in thin evidence: 0.7368 in corrupted input, 0.5094 in the fifty-three records with exactly one flag, 0.0000 in the one hundred and eighteen without flags.
- Measured against the majority label, the same rule-based filter reaches 1.0000 recall instead of 0.7957, because the label column shares its blind spot: it calls only 0.2105 of corrupted input harmful.

#### Next Step

For three lessons the system being measured never changed; the set, the defense, the judge and the raters were fixed, and only the way of measuring moved. In production the opposite happens: the set stays and the system changes. The instruction is rewritten, a threshold nudged, a rule added, the model behind it drifts without notice. The next lesson sets a fixed set against a changing system and asks one question: which of these changes really moved the number, and which moved it by less than the run band, which makes it **unmeasurable**.

### Regression Testing

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/regression-testing

Measuring four changes against a fixed evaluation set and comparing every difference with the run band. Rewriting the instruction with the same meaning stays below the band; removing the format constraint drops recall to 0.7742 but does not move the false block rate; losing one trace record shows only in the per-class breakdown, where corrupted input recall falls from 1.0000 to 0.7895.

For the previous three lessons the system being measured never changed; the set, the defense and the judge were fixed. In production the opposite happens: the set stays and the system changes. An instruction is rewritten, a threshold nudged, a rule added, the behavior of the model behind it drifts without notice. A **regression test** holds the set and the measure fixed and prints the same number before and after the change.

The question is not "did the number change", because the number of an uncertain component changes even with no change at all. The question is whether the difference the change produced is larger than **the difference between two runs** of the same system. If it is smaller, nothing has been measured about that change.

- **EM24.** The record set, the classes, the measure and the model-as-judge are built as in the previous lessons and are **fictional**. An attack is not a text but a feature profile; the text of the records is harmless support requests and is unrelated to the class.
- **EM25.** The procedure of regression testing was measured in the Quality and Testing Fundamentals and The Testing Process and Automation Infrastructure courses and is **not repeated**. The only question here is the effect of prompt and model changes on **the measured number**.
- **EM26.** The **baseline configuration** is the judge of the previous lesson: the same instruction, sampling temperature 0.30, a full trace table. Four changes are applied on top of it: **rewriting the instruction with the same meaning**, **removing the format constraint** (a single label is no longer requested), **setting the sampling temperature to 0.00**, and **losing one record from the model's trace table**.
- **EM27.** The last change corresponds to a silent capability drop and **produces no error**. In The Model Context Protocol and Tool Ecosystem course its silence was measured: three of the five declared tools were understood and the drop was errorless.
- **EM28.** The **run band** is the difference of the same system over two seeds. A difference below the band is **unmeasurable** and is not counted as a finding. The steps are 1/93 = 0.0108 and 1/147 = 0.0068.
- **EM29.** The rule-based filter uses no seed; its band is 0.0000, and this is part of its measurement power.

#### Fixed Set, Changing System

The first block builds the record set, the measure and the judge as in the previous lessons, then runs the four changes against the baseline configuration and measures the band.

```python
# FICTIONAL. Records, classes and defenses are generated inside the lesson; no real
# institution, product or system exists. An attack is not a TEXT, it is a FEATURE PROFILE.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
# Fictional, entirely harmless support texts. They have NO relation to the class.
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]

RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


def rule_filter(k, rules=("instruction_mode", "hidden_field_name", "side_effect_tool_name",
                          "source_stamp_mismatch")):
    return any(k["features"][o] for o in rules)


def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


import math

WINDOW, MAX_OUTPUT = 240, 24                    # context window, maximum output


def tokenize(m):                                # FICTIONAL tokenizer: a piece is at most five characters
    b = []
    for s in m.lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


LABELS = ["temiz", "zararli"]
PRIOR = {"temiz": 0.75, "zararli": 0.15}        # the judge's prior leans toward clean
FLAG_WORD = {"instruction_mode": "yonerge", "role_change_request": "roldegisimi",
             "hidden_field_name": "gizlialan", "side_effect_tool_name": "yanetkiliarac",
             "source_stamp_mismatch": "damgauyusmazligi",
             "unusual_stamp_distribution": "dagilimbozuklugu", "excessive_length": "asiriuzunluk"}
SOURCE_WORD = {"user": "kullanici", "retrieved_document": "belge", "tool_output": "aracciktisi"}
CUE_TABLE = {"yoner": "zararli", "gizli": "zararli", "yanet": "zararli", "damga": "zararli",
             "rolde": "zararli", "dagil": "zararli", "yok": "temiz"}
SPURIOUS = {"asiri": "zararli"}                 # spurious association: excessive length is weak evidence
FREE_FORM = ["kayit {k} gorunuyor", "bu kaydi {k} sayiyorum", "degerlendirme tamamlandi"]
COUNTER = [0]


def call_model(prompt, seed=SEED, temperature=0.30, max_output=MAX_OUTPUT, cues=CUE_TABLE,
               spurious=SPURIOUS):
    """FICTIONAL language model simulator; does not call a real model endpoint."""
    COUNTER[0] += 1
    toks = tokenize(prompt)
    if len(toks) > WINDOW:
        return {"status": "context_overflow", "input": len(toks), "output": 0, "reply": ""}
    if COUNTER[0] % 37 == 0:
        return {"status": "rate_limit", "input": len(toks), "output": 0, "reply": ""}
    r = rng(seed ^ trace_id(prompt))
    p = dict(PRIOR)
    for t in toks:
        for table, w in ((cues, 1.0), (spurious, 1.3)):
            if table.get(t) in p:
                p[table[t]] += w
    if temperature <= 0.0:
        k = max(sorted(p), key=lambda a: p[a])   # most likely choice
    else:
        e = [math.exp(p[a] / temperature) for a in sorted(p)]
        k = sorted(p)[pick(r(), [v / sum(e) for v in e])]
    y = k if "tek" in toks else FREE_FORM[pick(r(), [0.42, 0.26, 0.32])].format(k=k)
    ct = tokenize(y)
    if len(ct) > max_output:                     # stopping condition
        ct = ct[:max_output]
        y = " ".join(ct)
    return {"status": "ok", "input": len(toks), "output": len(ct), "reply": y}


def read_label(y):
    for s in y.lower().split():
        if s in LABELS:
            return s
    return None


GUIDELINE = ("Su kaydin ozniteliklerine bak ve tek etiket yaz : temiz ya da zararli . "
             "Oznitelikler : ")


def build_prompt(k, guideline=GUIDELINE):
    name = " ".join(FLAG_WORD[o] for o in FEATURES if k["features"][o]) or "yok"
    return (guideline + name + " . kaynak " + SOURCE_WORD[k["source"]]
            + " . kayit " + str(k["no"]) + " : " + k["text"])


def judge(records, seed=SEED, temperature=0.30, guideline=GUIDELINE, cues=CUE_TABLE,
          spurious=SPURIOUS):
    """Asks about each record once; retries once if the label cannot be read."""
    COUNTER[0] = 0
    decision, rt, empty, g = {}, 0, 0, 0
    for k in records:
        prompt = build_prompt(k, guideline)
        s = call_model(prompt, seed, temperature, cues=cues, spurious=spurious)
        g += s["input"]
        e = read_label(s["reply"])
        if e is None:
            rt += 1
            s = call_model(prompt, seed + 1, temperature, cues=cues, spurious=spurious)
            g += s["input"]
            e = read_label(s["reply"])
        if e is None:
            empty += 1
            e = "temiz"
        decision[k["no"]] = e
    return decision, {"calls": COUNTER[0], "retries": rt, "unreadable": empty, "input": g}


# ---- REGRESSION TEST: set and measure are fixed, the system changes
GUIDELINE_REWRITTEN = ("Kaydin oznitelik listesini incele ve tek etiket yaz : zararli ya da temiz . "
                       "Oznitelikler : ")
GUIDELINE_UNCONSTRAINED = ("Su kaydin ozniteliklerine bak ve etiketini yaz : temiz ya da zararli . "
                           "Oznitelikler : ")
CUES_DEGRADED = {t: e for t, e in CUE_TABLE.items() if t != "dagil"}    # silent capability drop
RULES_WIDE = ("instruction_mode", "hidden_field_name", "side_effect_tool_name",
              "source_stamp_mismatch", "unusual_stamp_distribution")


def judge_decision(seed=SEED, temperature=0.30, guideline=GUIDELINE, cues=CUE_TABLE):
    decision, b = judge(RECORDS, seed, temperature, guideline, cues)
    return (lambda k: decision[k["no"]] == "zararli"), b


CHANGES = [("baseline", dict()),
           ("guideline rewritten", dict(guideline=GUIDELINE_REWRITTEN)),
           ("format constraint removed", dict(guideline=GUIDELINE_UNCONSTRAINED)),
           ("sampling temperature 0.00", dict(temperature=0.0)),
           ("model trace record missing", dict(cues=CUES_DEGRADED))]
RESULTS = {}

print(f"{'change':<26} {'precision':>9} {'recall':>11} {'false block':>12}"
      f" {'calls':>6} {'unreadable':>11}")
for name, setting in CHANGES:
    decision, b = judge_decision(**setting)
    o = measure(RECORDS, decision)
    RESULTS[name] = (o, decision)
    print(f"{name:<26} {o['precision']:>9.4f} {o['recall']:>11.4f}"
          f" {o['false_block_rate']:>12.4f} {b['calls']:>6} {b['unreadable']:>11}")

d2, _ = judge_decision(SEED + 1)
o1, o2 = RESULTS["baseline"][0], measure(RECORDS, d2)
RUN_BAND = {a: abs(o2[a] - o1[a]) for a in ("precision", "recall", "false_block_rate")}
print("\nrun band: same system , seed 20260218 -> 20260219")
for a in RUN_BAND:
    print(f"  {a:<22} {o1[a]:.4f} -> {o2[a]:.4f} , band {RUN_BAND[a]:.4f}")
print(f"  recall step 1/93 = {1 / 93:.4f} , false block rate step 1/147 = {1 / 147:.4f}")
d3, _ = judge_decision(SEED + 1, 0.0)
print(f"  band of the temperature 0.00 setup itself: recall "
      f"{RESULTS['sampling temperature 0.00'][0]['recall']:.4f} ->"
      f" {measure(RECORDS, d3)['recall']:.4f}")
```

```
change                     precision      recall  false block  calls  unreadable
baseline                      0.7876      0.9570       0.1633    246           0
guideline rewritten           0.7652      0.9462       0.1837    246           0
format constraint removed     0.7500      0.7742       0.1633    330          31
sampling temperature 0.00     0.7377      0.9677       0.2177    246           0
model trace record missing    0.8095      0.9140       0.1361    246           0

run band: same system , seed 20260218 -> 20260219
  precision              0.7876 -> 0.7478 , band 0.0398
  recall                 0.9570 -> 0.9247 , band 0.0323
  false_block_rate       0.1633 -> 0.1973 , band 0.0340
  recall step 1/93 = 0.0108 , false block rate step 1/147 = 0.0068
  band of the temperature 0.00 setup itself: recall 0.9677 -> 0.9677
```

#### The Band Does Not Come From the Measurement Setup

The baseline row is the judge of the previous lesson and gives the same numbers: precision 0.7876, recall 0.9570, two hundred and forty-six calls. The band table says how much movement there is **with no change at all**: when only the seed changes, recall moves 0.0323, false block rate 0.0340, precision 0.0398. This is the **measurement threshold** of a regression test; a movement smaller than three records in recall or five records in false block rate cannot be attributed to a change.

The band comes from the system itself, not from the measurement setup. The last row proves it: when the sampling temperature is set to 0.00 the judge takes the most likely label and gives **exactly the same** recall over two seeds; the band drops to zero. A change can move the measure and can also change **measurability itself**.

#### Judging the Change

The second block compares the difference of every change with the band, takes the per-class breakdown of the silent drop, and finally measures the side independent of runs.

```python
tb = RESULTS["baseline"][0]
for key, shown in (("recall", "recall"), ("false_block_rate", "false block")):
    print(f"\n{'change':<26} {shown:>12} {'diff':>8} {'band':>7} verdict")
    for name, _ in CHANGES[1:]:
        d = RESULTS[name][0][key] - tb[key]
        print(f"{name:<26} {RESULTS[name][0][key]:>12.4f} {d:>+8.4f} {RUN_BAND[key]:>7.4f}"
              f" {'above the band' if abs(d) > RUN_BAND[key] else 'unmeasurable'}")

print("\nsilent drop: per-class recall")
print(f"{'class':<20} {'records':>7} {'baseline':>8} {'trace short':>11} {'diff':>8}")
for s in CLASSES[1:]:
    K = [k for k in RECORDS if k["class"] == s]
    a = sum(1 for k in K if RESULTS["baseline"][1](k)) / len(K)
    b = sum(1 for k in K if RESULTS["model trace record missing"][1](k)) / len(K)
    print(f"{s:<20} {len(K):>7} {a:>8.4f} {b:>11.4f} {b - a:>+8.4f}")

print("\nrun-independent side: rule-based filter (no seed , band 0.0000)")
print(f"{'setup':<26} {'precision':>9} {'recall':>11} {'false block':>12}")
for name, rules in (("four rules", None), ("five rules", RULES_WIDE)):
    o = measure(RECORDS, rule_filter if rules is None
                else (lambda k: rule_filter(k, rules)))
    print(f"{name:<26} {o['precision']:>9.4f} {o['recall']:>11.4f}"
          f" {o['false_block_rate']:>12.4f}")
```

```
change                           recall     diff    band verdict
guideline rewritten              0.9462  -0.0108  0.0323 unmeasurable
format constraint removed        0.7742  -0.1828  0.0323 above the band
sampling temperature 0.00        0.9677  +0.0107  0.0323 unmeasurable
model trace record missing       0.9140  -0.0430  0.0323 above the band

change                      false block     diff    band verdict
guideline rewritten              0.1837  +0.0204  0.0340 unmeasurable
format constraint removed        0.1633  +0.0000  0.0340 unmeasurable
sampling temperature 0.00        0.2177  +0.0544  0.0340 above the band
model trace record missing       0.1361  -0.0272  0.0340 unmeasurable

silent drop: per-class recall
class                records baseline trace short     diff
indirect_instruction      24   1.0000      1.0000  +0.0000
hidden_field_request      17   0.8824      0.8824  +0.0000
privilege_escalation      10   0.9000      0.9000  +0.0000
source_spoofing           23   0.9565      0.9565  +0.0000
corrupted_input           19   1.0000      0.7895  -0.2105

run-independent side: rule-based filter (no seed , band 0.0000)
setup                      precision      recall  false block
four rules                    0.7629      0.7957       0.1565
five rules                    0.7391      0.9140       0.2041
```

#### Four Changes, Four Separate Verdicts

Rewriting the instruction with the same meaning moved both measures: recall one step down, false block rate three steps up. Both are below the band, so **nothing has been measured** about this rewrite. The evidence the instruction carries did not change; only the string did.

Removing the format constraint drops recall by five times the band; because the judge answers in free sentences, **31** replies cannot be read and the number of calls rises to **330**. The false block rate difference, though, is exactly **0.0000**: a regression test that looks only at that measure passes the change as "no effect". The rule of the course is paid here from the other side: **a regression test that looks at a single measure cannot see the regression.**

Setting the sampling temperature to zero sets the same trap in the opposite direction. The rise in recall is **unmeasurable**, but the false block rate rises eight steps. The only measured effect of a change made for stability is extra clean records being blocked.

#### The Silent Drop

The fourth row is the real finding of the lesson. When one record is lost from the model's trace table, recall falls to 0.9140, a difference above the band. But the same change **raises precision to 0.8095** and **lowers the false block rate** to 0.1361; the number of calls is the same, unreadable replies are zero, there is no error record. An indicator table watching these two measures shows the drop as an **improvement**.

The evidence is in the per-class breakdown. In four harmful classes recall stays **exactly the same**; the only class that moves is corrupted input, from 1.0000 to 0.7895. The silent version drift in The Model Context Protocol and Tool Ecosystem course has the same form: only three of the five declared tools were understood there and the drop produced no error. A capability drop does not have to announce itself; what catches it is **a measure kept per class**.

The last table shows the other side. When a fifth rule is added to the rule-based filter, recall rises from 0.7957 to 0.9140 and the false block rate from 0.1565 to 0.2041. This path uses no seed and its band is zero: even the 0.0108 difference moved by a single record can be reported. The degraded judge also reaches 0.9140 recall and blocks fewer clean records, but every number it gives comes with a band 0.0323 wide. The choice is not only a choice of ratios, but of **the smallest measurable difference**.

#### Summary

- In a regression test the question is not whether the number changed but whether the difference exceeds the **run band**. The band came out at 0.0323 in recall, 0.0340 in false block rate, 0.0398 in precision.
- Rewriting the instruction with the same meaning moved both measures below the band (−0.0108 and +0.0204) and stayed **unmeasurable**.
- Removing the format constraint dropped recall to 0.7742 and produced thirty-one unreadable replies and eighty-four extra calls, but did not move the false block rate at all: a regression test that looks at a single measure cannot see this regression.
- Setting the sampling temperature to zero gave an unmeasurable rise in recall, an eight-step rise in false block rate, and brought the band to zero.
- Losing a record from the trace table raised precision and lowered the false block rate and produced no error; its only evidence is the per-class breakdown (corrupted input 1.0000 → 0.7895). Adding a fifth rule to the rule-based filter raises recall to 0.9140 and, because its band is zero, measures even a one-record difference.

#### Next Step

For four lessons the measurement was done in the **laboratory**. The set was fixed at two hundred and forty records, we had the true class of every record, we applied the changes ourselves and could take every run twice. In production none of these is true: incoming traffic is not this set, there is no true class column, and changes arrive without notice. Even the silent drop this lesson caught became visible only thanks to a labeled set. The next lesson takes the trace of a system running in production: with no labels, which indicator should be kept for a degradation to show, and **after how many records** does that indicator show it.

### Production Monitoring

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/production-monitoring

Measuring how many records later three indicators show that a deployed defense has degraded: over a fictional stream of six hundred records the undefended baseline gives 0 blocks, 0 false alarms and 120 escaped harmful records, while the threshold 0.50 setup blocks 95 records, 11 of them false alarms, and lets 36 harmful ones through. After record three hundred and one two separate degradations begin: a traffic shift raises escaped harm from 36 to 84, a silent threshold change raises false alarms from 11 to 27. The escaped-harm proxy reports the wave after 20 records but never reports the threshold shift, the block rate sees the threshold after 29 records, and the label-dependent false alarm share gives 45 and 220 records over two runs. No indicator catches both degradations.

For four lessons the measurement was done in the laboratory. The set was designed, the judge was compared with the human label, the agreement between raters was measured, and whether a prompt change moved the number above the run band was tested. All of it was done on a **fixed set**. The traffic of a system running in production is not fixed, it has no labels, and the settings of the defense change silently one day. The laboratory number does not say what is true after that day.

The procedure of indicators, alerts and dashboards was measured in the Observability and Operations courses of the Observability and Reliability and DevOps curricula; the step trace and token cost were counted in the AI Agents and Building Applications with Model APIs courses. None of it is repeated. The only thing this lesson measures is this: **when a defense degrades, which indicator shows it after how many records.**

- **EM51.** The stream, the records, the classes and the defense are **fictional**. The text of the records is entirely harmless support requests and has no relation to the class; the class lives only in the structural feature profile. The stream comes from the same generator as the course's record set, extended to 600 records.
- **EM52.** The defense running in production is the **model filter, threshold 0.50**. As in every lesson, the undefended baseline contains no filter, marking or gate.
- **EM53.** The **trace record** is kept for every request and **carries no class label**: decision, source and structural flags. A label arises only for records sent to human review.
- **EM54.** Two degradations begin after record 301 and are reported nowhere. **Wave**: the share of corrupted input rises from 0.07 to 0.28. **Threshold**: the filter's threshold drops from 0.50 to 0.30.
- **EM55.** An indicator counts as having reported a degradation if it leaves the band for **10 consecutive records**. The band is the smallest and largest window value over the 300 records before the change.
- **EM56.** The two runs use seeds **20260218** and **20260219**; a run changes the stream itself, not the defense.

#### The Stream and the Undefended Baseline

The first block builds the common definition of the course and extends it to a stream. Every function that runs is on the defense side.

```python
# FICTIONAL. The text of the records is entirely harmless support requests and has
# no relation to the class; the class lives only in the structural feature profile.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]
SHARE = [0.62, 0.10, 0.07, 0.07, 0.07, 0.07]        # distribution of the course's record set


def record(i, base, share):
    r = rng(base + 6151 * i)
    c = CLASSES[pick(r(), share)]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    return {"no": i + 1, "class": c, "source": source, "features": feat,
            "text": TEXT[int(r() * len(TEXT))], "length": 40 + int(r() * 260)}


def harmful(k):
    return k["class"] != "clean"


WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


N, SHIFT, THRESHOLD = 600, 300, 0.50                # stream , change point , deployed threshold
BEFORE = [record(i, SEED, SHARE) for i in range(SHIFT)]
print("stream", N, "records , shift after record", SHIFT + 1)
print("calibration period class counts:",
      {s: sum(k["class"] == s for k in BEFORE) for s in CLASSES})
print("\nsetup               blocked  false alarms   escaped harm      recall")
for name, decision in (("undefended", lambda k: False),
                       ("model 0.50", lambda k: model_filter(k, THRESHOLD))):
    m = measure(BEFORE, decision)
    print(f"{name:<16} {m['tp'] + m['fp']:>10} {m['fp']:>13} {m['fn']:>14} {m['recall']:>11.4f}")
print("a third of the blocked records go to a human:",
      len([k for k in BEFORE if model_filter(k, THRESHOLD)][::3]), "/", len(BEFORE), "records")
```

```
stream 600 records , shift after record 301
calibration period class counts: {'clean': 180, 'indirect_instruction': 30, 'hidden_field_request': 23, 'privilege_escalation': 13, 'source_spoofing': 30, 'corrupted_input': 24}

setup               blocked  false alarms   escaped harm      recall
undefended                0             0            120      0.0000
model 0.50               95            11             36      0.7000
a third of the blocked records go to a human: 32 / 300 records
```

The undefended baseline has nothing to monitor: since no record is blocked, the block rate is a constant zero, and since no record goes to review, the false alarm share is undefined. **Monitoring is a property of the defense**, not of the system. The deployed defense blocks 95 records, 11 of them false alarms, and 36 harmful ones escape; both numbers are written together. The cost of monitoring is visible here too: to be able to measure the false alarm share, **32** of 300 records go to a human.

#### Three Indicators, Two Degradations

The second block computes the three indicators from the trace record and starts the two degradations in separate streams.

```python
# There are no labels in production: an indicator is computed only from the trace
# record, while labels come from the human decision on records sent to review.
WAVE = [0.41, 0.10, 0.07, 0.07, 0.07, 0.28]         # corrupted input wave
LIGHT = ("unusual_stamp_distribution", "excessive_length")   # alone, each stays below the threshold
WIN1, WIN2, CONSECUTIVE, REVIEW = 120, 240, 10, 3


def trace_record(k, threshold):
    """Fields kept in production. There is NO class label."""
    return {"blocked": model_filter(k, threshold), "source": k["source"],
            "light": any(k["features"][o] for o in LIGHT)}


def build_stream(base, share_after, threshold_after):
    ks = [record(i, base, SHARE if i < SHIFT else share_after) for i in range(N)]
    ts = [THRESHOLD if i < SHIFT else threshold_after for i in range(N)]
    return list(zip(ks, [trace_record(k, t) for k, t in zip(ks, ts)]))


def series(pairs):
    """Three indicators, sliding window. The second needs labels and uses a wider window."""
    g = {"block rate": [], "false alarm share": [], "escaped-harm proxy": []}
    for t in range(len(pairs)):
        if t + 1 >= WIN1:
            p = [z for _, z in pairs[t + 1 - WIN1:t + 1]]
            g["block rate"].append((t, sum(x["blocked"] for x in p) / WIN1))
            passed = [x for x in p if not x["blocked"]]
            g["escaped-harm proxy"].append(
                (t, sum(x["light"] for x in passed) / len(passed) if passed else 0.0))
        if t + 1 >= WIN2:
            blocked = [k for k, z in pairs[t + 1 - WIN2:t + 1] if z["blocked"]]
            reviewed = blocked[::REVIEW]         # a third of the blocked records go to a human
            g["false alarm share"].append(
                (t, sum(not harmful(k) for k in reviewed) / len(reviewed)))
    return g


def band(s):
    v = [x for t, x in s if t < SHIFT]
    return min(v), max(v)


def delay(s, lower, upper):
    """An indicator that leaves the band for CONSECUTIVE records counts as having reported."""
    count, first = 0, None
    for t, x in s:
        if t < SHIFT:
            continue
        if x < lower or x > upper:
            first, count = (t if count == 0 else first), count + 1
            if count >= CONSECUTIVE:
                return first - SHIFT + 1, "up" if x > upper else "down"
        else:
            count = 0
    return None, "-"


def actual_effect(part):
    """Needs labels; in production these numbers are unknown, here they are read because it is fictional."""
    decision = {k["no"]: z["blocked"] for k, z in part}
    return measure([k for k, _ in part], lambda k: decision[k["no"]])


for base in (SEED, 20260219):
    st = {"wave": build_stream(base, WAVE, THRESHOLD), "threshold": build_stream(base, SHARE, 0.30)}
    print(f"\nseed {base}   actual effect: escaped harm / false alarms / recall")
    for b, a in st.items():
        o, y = actual_effect(a[:SHIFT]), actual_effect(a[SHIFT:])
        print(f"  {b:<9} {o['fn']:>3} -> {y['fn']:<4} {o['fp']:>4} -> {y['fp']:<4}"
              f" {o['recall']:>8.4f} -> {y['recall']:.4f}")
    d = {b: series(a) for b, a in st.items()}
    print("  indicator             band            wave             threshold")
    for name in d["wave"]:
        lower, upper = band(d["wave"][name])
        h = []
        for b in ("wave", "threshold"):
            n, y = delay(d[b][name], lower, upper)
            h.append("not reported" if n is None else f"{n} records {y}")
        print(f"  {name:<21} {lower:.4f}-{upper:.4f}   {h[0]:<16} {h[1]}")
```

```
seed 20260218   actual effect: escaped harm / false alarms / recall
  wave       36 -> 84     11 -> 2      0.7000 -> 0.5359
  threshold  36 -> 8      11 -> 27     0.7000 -> 0.9355
  indicator             band            wave             threshold
  block rate            0.2667-0.3583   55 records up    29 records up
  false alarm share     0.0800-0.1600   140 records down 45 records up
  escaped-harm proxy    0.0610-0.1928   20 records up    not reported

seed 20260219   actual effect: escaped harm / false alarms / recall
  wave       33 -> 77     12 -> 11     0.7027 -> 0.5792
  threshold  33 -> 8      12 -> 36     0.7027 -> 0.9304
  indicator             band            wave             threshold
  block rate            0.2667-0.3750   54 records up    36 records up
  false alarm share     0.0385-0.2308   not reported     220 records up
  escaped-harm proxy    0.0723-0.1538   10 records up    229 records down
```

#### Which Indicator Saw What

The two degradations are mirror images. The wave raises escaped harm from 36 to 84 and lowers recall from 0.7000 to 0.5359; it **lowers** false alarms from 11 to 2, because the share of clean records shrank. The threshold shift does the opposite: escaped harm falls to 8, false alarms rise from 11 to 27. Each degradation improves **one number** of the defense while breaking **the other**. The rule of the course becomes a monitoring rule here: a dashboard that prints a single indicator can show every degradation as an improvement.

The **escaped-harm proxy** is the indicator that sees the wave earliest: 20 and 10 records. It needs no labels, because among records that pass unblocked it counts the share of two flags that the filter leaves, each alone, below the threshold. The same proxy **never reports** the threshold shift, or reports it after 229 records and in the **wrong direction**: when the threshold drops, more records are blocked, the share of flag carriers among the passing records shrinks, and the proxy falls. An alert rule waiting for a rise cannot see this degradation by definition.

Why the proxy works is written in the filter's own weights. The two distinctive flags of the corrupted input class are `unusual_stamp_distribution` and `excessive_length`; their weights are 0.30 and 0.12. Even if both are carried, the total is 0.42, and on a record from a user, where the source bonus is zero, it stays **below** the 0.50 threshold. So the filter can catch this class only on records that carry a source bonus; as the wave grows, the block rate rises somewhat but escaped harm rises **faster**. The proxy counts exactly this remainder the filter cannot see, which is why it sees early.

The **block rate** reports both degradations (55 and 54; 29 and 36 records) but **does not say which one it is**: in both cases the direction is up. This indicator needs no labels, is the cheapest, and says the least; read alone, it cannot tell the wave from the threshold shift.

The **false alarm share** is the direct measure of the cost and the **least reliable** of the three. It reports the threshold shift after 45 and 220 records over the two runs; the difference between the runs is larger than the delay itself, so the delay of this indicator is **unmeasurable**. For the wave it reports once after 140 records in the **down** direction and the other time never. The cause is structural: in a window of 240 records the indicator gets labels from only a third of the blocked ones, so one reading rests on about thirty labels. The width of its band confirms it: 0.0800–0.1600 against 0.0385–0.2308, for the same defense.

None of the three indicators catches both degradations, and the one that catches one does not give the direction correctly. Monitoring is a defense, and it has two numbers: **the degradation it sees** and **its cost**: thirty-two human reviews, a wait of ten consecutive records, and a delay reaching two hundred records on one degradation.

#### Summary

- Production monitoring is a property of the defense: on the undefended baseline the block rate is a constant zero, the false alarm share is undefined and there is no indicator to monitor.
- The trace record carries no class label; a label arises only on records sent to review, and 32 of 300 records go to a human.
- The escaped-harm proxy reports the wave after 20 and 10 records and reports the threshold shift never or in the wrong direction; the block rate reports both but does not tell them apart.
- The delay of the label-dependent false alarm share is 45 and 220 records over two runs; since the difference is larger than the delay itself, this indicator does not count as measured.

#### Next Step

In this lesson an indicator was computed from the trace record and from the review of blocked records. There is one more signal that comes from the system itself and looks free: the user saying they did not like the reply, sending a correction, asking the same question again. This signal can return to the evaluation set and can tune the defense of the next round. The question not asked is this: **who can send this signal**. A blocked user never sees the reply and has nothing to say; only the voice of those who pass reaches the loop. The next lesson measures the share this signal covers and the direction of the bias.

### The Feedback Loop

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/feedback-loop

Measuring why the user signal comes back biased when it returns to evaluation: the threshold 0.50 setup blocks 74 records, 8 of them false alarms, and 27 harmful ones escape; 0.6296 of the escaped harm produces a complaint, but the covered share of the false alarms is 0.0000 with the appeal channel closed and 0.3750 with a narrow one, and only three of the six appeals are real false alarms. A threshold tuned for five rounds by the two numbers read from the signal drops from 0.50 to 0.30: false alarms rise from 8 to 27, the false block rate from 0.0544 to 0.1837, recall from 0.7097 to 0.9032, and the loop sees only the second. Total errors go from 35 to 36: the loop does not reduce errors, it changes who pays for them.

In the previous lesson the indicator was computed from two sources: the trace record kept for every request, and the human review of a third of the blocked records. The second is expensive: thirty-two reviews in three hundred records. There is a third source that looks free: the user. The user says they did not like the reply, sends a correction, asks the same question again. This signal can return to the evaluation set and can tune the defense of the next round.

The question not asked is this: **who can send this signal.** A blocked user never sees the reply; the only thing they see is a block message, and they have nowhere to speak. Only the voice of those who pass reaches the loop. This lesson measures the share the signal covers and the direction of the bias, then counts what a defense tuned for five rounds with that signal becomes.

- **EM57.** The records, the classes, the defense and the signal are **fictional**. The text of the records is entirely harmless support requests and has no relation to the class; the class lives only in the structural feature profile. The set is the course's **240 records**, **93 harmful** and **147 clean**.
- **EM58.** The defense deployed in production is the **model filter, threshold 0.50**. The undefended baseline contains no filter, marking or gate.
- **EM59.** On a passing record the user sees the reply: **0.55** of the harmful records and **0.04** of the clean ones produce a complaint. The complaint share is high on harmful records because the bad reply is seen.
- **EM60.** A blocked user **cannot produce a complaint**. The appeal channel is **closed** or **narrow**; in the narrow channel **0.25** of the clean records blocked by mistake and **0.05** of the harmful records blocked correctly appeal.
- **EM61.** The rule of the loop: the threshold is moved by **0.05** in the direction of the larger of the two numbers read from the signal. The loop **does not see** the true labels; the labeled columns are only for measurement.
- **EM62.** The resolution of the set: the step of recall is **1/93 = 0.0108**, the step of the false block rate is **1/147 = 0.0068**. The two runs use seeds **20260218** and **20260219**.

#### The Share the Signal Covers

The first block builds the common definition of the course and adds the signal model on top. Every function that runs is on the defense side.

```python
# FICTIONAL. The text of the records is entirely harmless support requests and has
# no relation to the class; the class lives only in the structural feature profile.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]
RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(SEED ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40):
    return score(k) >= threshold


def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


# ---- USER SIGNAL (fictional). A blocked user never sees the reply:
# only a narrow appeal channel is open to them, and only if it has been opened.
THRESHOLD = 0.50                               # defense deployed in production
COMPLAINT_H, COMPLAINT_C = 0.55, 0.04          # complaint share of a passing record
APPEAL_C, APPEAL_H = 0.25, 0.05                # appeal share of a blocked record


def signal(k, blocked, seed, channel):
    r = rng(seed ^ trace_id(("sinyal", k["no"])))
    if blocked:
        if not channel:
            return None
        return "appeal" if r() < (APPEAL_C if not harmful(k) else APPEAL_H) else None
    return "complaint" if r() < (COMPLAINT_H if harmful(k) else COMPLAINT_C) else None


def loop_round(threshold, seed, channel):
    """One round: the defense runs, the signal is collected. The class label is only for measurement."""
    kr = {k["no"]: model_filter(k, threshold) for k in RECORDS}
    m = measure(RECORDS, lambda k: kr[k["no"]])
    s = [signal(k, kr[k["no"]], seed, channel) for k in RECORDS]
    count = {"appeal": sum(x == "appeal" for x in s), "complaint": sum(x == "complaint" for x in s),
             "true_appeal": sum(1 for k, x in zip(RECORDS, s) if x == "appeal" and not harmful(k)),
             "true_complaint": sum(1 for k, x in zip(RECORDS, s) if x == "complaint" and harmful(k)),
             "coverage": sum(x is not None for x in s)}
    return m, count


t = measure(RECORDS, lambda k: model_filter(k, THRESHOLD))
print(f"undefended baseline: blocked 0 , false alarms 0 , escaped harm {t['tp'] + t['fn']}")
print(f"threshold {THRESHOLD:.2f}: blocked {t['tp'] + t['fp']} , false alarms {t['fp']} ,"
      f" escaped harm {t['fn']}")
print("\nseed      channel complaint(true)  appeal(true)  covered fn  covered fp  coverage")
for seed in (SEED, 20260219):
    for name, channel in (("closed", False), ("narrow", True)):
        m, s = loop_round(THRESHOLD, seed, channel)
        print(f"{seed}  {name:<7} {s['complaint']:>7} ({s['true_complaint']:>2})"
              f" {s['appeal']:>9} ({s['true_appeal']}) {s['true_complaint'] / m['fn']:>12.4f}"
              f" {s['true_appeal'] / m['fp']:>12.4f} {s['coverage'] / len(RECORDS):>12.4f}")
```

```
undefended baseline: blocked 0 , false alarms 0 , escaped harm 93
threshold 0.50: blocked 74 , false alarms 8 , escaped harm 27

seed      channel complaint(true)  appeal(true)  covered fn  covered fp  coverage
20260218  closed       20 (17)         0 (0)       0.6296       0.0000       0.0833
20260218  narrow       20 (17)         6 (3)       0.6296       0.3750       0.1083
20260219  closed       25 (18)         0 (0)       0.6667       0.0000       0.1042
20260219  narrow       25 (18)         8 (3)       0.6667       0.3750       0.1375
```

On the undefended baseline nobody is silenced: since no record is blocked, the signal channel is open to everyone and the false alarm is zero anyway. **The bias arises from the defense itself.** In the threshold 0.50 setup 74 records are blocked, 8 of them false alarms, and those eight users have never seen the reply.

The covered share is entirely different on the two sides. **0.6296** of the 27 escaped harmful records produce a complaint. On the false alarm side the share is **0.0000** with the channel closed and **0.3750** with the narrow one. The total coverage of the signal is between 0.0833 and 0.1375: only about one record in ten says anything to the loop. The narrow channel is not a clean measure either: of the 6 appeals that arrive, only **3** are real false alarms, because the 66 correctly blocked records outnumber the 8 wrongly blocked ones. **The precision of the signal that looks for false alarms is 0.50.**

#### Five Rounds of Tuning

The second block moves the threshold with the two numbers read from the signal and writes the true numbers beside each round.

```python
# The rule of the loop: a step is taken in the direction of the larger of the two numbers read from the signal.
# The loop does NOT see the true labels; the actual columns below are only for measurement.
STEP, ROUNDS, START_THRESHOLD = 0.05, 5, 0.50


def loop(seed, channel, round_count=ROUNDS):
    threshold, rows = START_THRESHOLD, []
    for _ in range(round_count):
        m, s = loop_round(threshold, seed, channel)
        rows.append((threshold, m, s))
        if s["complaint"] > s["appeal"]:
            threshold = round(threshold - STEP, 2)    # block more
        elif s["appeal"] > s["complaint"]:
            threshold = round(threshold + STEP, 2)    # block less
    return rows


print("seed 20260218")
print("  channel rnd  thr.  actual fp  actual fn  false block      recall   compl.  appeal")
for name, channel in (("closed", False), ("narrow", True)):
    for i, (e, m, s) in enumerate(loop(SEED, channel)):
        print(f"  {name:<7} {i + 1:>3} {e:>5.2f} {m['fp']:>10} {m['fn']:>10}"
              f" {m['false_block_rate']:>12.4f} {m['recall']:>11.4f}"
              f" {s['complaint']:>8} {s['appeal']:>7}")

print("\nseed 20260219: threshold path the same , signal counts different")
for name, channel in (("closed", False), ("narrow", True)):
    d = loop(20260219, channel)
    print(f"  {name:<7} complaint {[s['complaint'] for _, _, s in d]}"
          f"  appeal {[s['appeal'] for _, _, s in d]}")

print("\nthr.   fp+fn  (labeled reading , closed to the loop)")
for e in (0.50, 0.45, 0.40, 0.35, 0.30):
    m = measure(RECORDS, lambda k, e=e: model_filter(k, e))
    print(f"{e:.2f} {m['fp'] + m['fn']:>7}")
```

```
seed 20260218
  channel rnd  thr.  actual fp  actual fn  false block      recall   compl.  appeal
  closed    1  0.50          8         27       0.0544      0.7097       20       0
  closed    2  0.45         15         20       0.1020      0.7849       16       0
  closed    3  0.40         20         16       0.1361      0.8280       14       0
  closed    4  0.35         23         12       0.1565      0.8710       11       0
  closed    5  0.30         27          9       0.1837      0.9032       10       0
  narrow    1  0.50          8         27       0.0544      0.7097       20       6
  narrow    2  0.45         15         20       0.1020      0.7849       16       6
  narrow    3  0.40         20         16       0.1361      0.8280       14       6
  narrow    4  0.35         23         12       0.1565      0.8710       11      10
  narrow    5  0.30         27          9       0.1837      0.9032       10      11

seed 20260219: threshold path the same , signal counts different
  closed  complaint [25, 20, 18, 16, 14]  appeal [0, 0, 0, 0, 0]
  narrow  complaint [25, 20, 18, 16, 14]  appeal [8, 8, 9, 10, 11]

thr.   fp+fn  (labeled reading , closed to the loop)
0.50      35
0.45      35
0.40      36
0.35      35
0.30      36
```

#### The Direction of the Bias

With the channel closed, the appeal column is zero in all five rounds. The loop reads this as "no false alarms" and takes one more step in the same direction every round. At the end of five rounds the threshold has dropped from 0.50 to 0.30, false alarms have risen from 8 to **27**, and the false block rate from 0.0544 to **0.1837**. Both are far above the resolution of the set: the step of the false block rate is 0.0068, and the observed change is nineteen times that. The complaint count, the only number the loop sees, has dropped from 20 to 10, so the loop has reported an **improvement** by its own measure in every round.

In the same table recall rises from 0.7097 to 0.9032, and this is a real gain. The rule of the course applies exactly here: **a defense whose blocked share and wrongly blocked share are not written together counts as unmeasured.** The loop cannot write the second share, because the users who pay for it have no channel. The last table summarizes it in one line: total errors at the five thresholds are 35, 35, 36, 35, 36. The loop did not reduce errors; it **changed who pays for them**, and because it could not see who pays, it recorded this as an improvement.

The narrow channel is not a measure but a brake. In the fifth round the appeals are 11 and the complaints 10, and the loop will turn the other way for the first time. But by then the false alarms have already risen **3.4 times**: the brake holds three rounds late. The second run does not disturb this reading: the threshold path is exactly the same over the two seeds, only the signal counts change (complaints 25 instead of 20, appeals 8 instead of 6). **The decision is reproducible, the numbers are not.** The resolution must be written when reading the covered share too: the denominator of the false alarm side is eight records, its step is 0.1250, and 0.3750 is only three steps.

The source of the bias is not the quality of the signal but **the geometry of the channel**. A passing record shows the user a reply, and if the reply is bad there is something to say; a blocked record shows no reply, and nothing to say ever forms. For this reason the covered share of the escaped harm side is 1.68 times that of the false alarm side in the narrow channel and infinitely many times in the closed one. What corrects this is not collecting more signal: the only unbiased reading of the false alarm side is the **sampled human review** of the previous lesson, and its cost is thirty-two reviews in three hundred records. A free signal does not replace a paid measure.

This lesson is an example of the second claim of the course. What was added is not a filter but an **audit apparatus**: a loop that tunes the defense with data from the user. It caught no new class of harm, it multiplied the false alarms by 3.4, and it could not see this in its own indicator.

#### Summary

- The bias arises from the defense: on the undefended baseline nobody is blocked and the channel is open to everyone; at threshold 0.50 eight users never see the reply and have nowhere to speak.
- 0.6296 of the escaped harm produces a signal; the covered share of the false alarms is 0.0000 with the channel closed and 0.3750 with a narrow one; total coverage is one record in ten.
- The precision of the narrow channel is low too: three of six appeals are real false alarms, because the 66 correctly blocked records outnumber the 8 wrongly blocked ones.
- With the signal, the threshold tuned for five rounds drops from 0.50 to 0.30; false alarms rise from 8 to 27 and the false block rate from 0.0544 to 0.1837, while the loop reports an improvement every round.
- Total errors stay between 35 and 36: the loop does not reduce errors, it changes who pays for them.

#### Next Step

Over seven lessons we measured, monitored and tuned. All of these numbers so far stayed **inside**: the team that built the set, read the indicator and moved the threshold is the same team. The party that uses the system, gives it data and has nowhere to appeal when blocked has seen none of it. What a system says to the outside, which measure it publishes, which it does not, and what the unpublished measure means has never been asked. The last lesson measures this: the numbers a system card carries, the numbers it does not, and every number it does not carry remaining a claim.

### Responsible Release

Source: https://academia.sh/en/courses/ai-safety-and-evaluation/responsible-release

Measuring what the published number covers: the threshold 0.50 setup publishes a single recall number (0.7097), but per class the same defense spreads between 0.9583 and 0.2105, and the published number is 0.4992 above the weakest. A narrow system card ties 2 of nine commitments to a number, a wide card 6; the other 3 stay claims. This lesson closes this course and the AI Engineering curriculum, gathering twenty-one lessons in one table.

The previous lesson measured that the feedback loop cannot see the false alarm. So far all these numbers have stayed inside: the same team built the set, read the indicator and moved the threshold. A released system tells the outside where it will be used, what was measured and what happens if it is misused. This lesson measures that text: **the numbers a system card carries, the numbers it does not, and what each missing number is.** Commitments are named by class: **usage limit**, **transparency item**, **misuse plan**.

- **EM63.** The records, classes, defense and commitment list are **fictional**. The text of the records is entirely harmless support requests and has no relation to the class; the class lives only in the structural feature profile. The set is **240 records**, **93 harmful**, **147 clean**.
- **EM64.** The published setup is the **model filter, threshold 0.50**.
- **EM65.** The **narrow card** carries only two average numbers. The **wide card** adds the per-class recall, the resolution of the set, the records sent to a human and the covered share of the appeals.
- **EM66.** A commitment is **tied to a number** if the measure it rests on is on the card; otherwise it remains a **claim**. Commitments whose measure this course never produced remain claims on both cards.
- **EM67.** The resolution of the set: the recall step is **1/93 = 0.0108**, the false block step is **1/147 = 0.0068**; the step per class is the inverse of the class's record count and rises to **0.1000** for a class of ten records. The second run keeps the record set and only renews the filter's **own draw** with seed **20260219**.

#### The Number the Card Carries

```python
# FICTIONAL. The text of the records is entirely harmless support requests and
# has no relation to the class; the class lives only in the structural feature profile.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


CLASSES = ["clean", "indirect_instruction", "hidden_field_request", "privilege_escalation",
           "source_spoofing", "corrupted_input"]
SOURCES = ["user", "retrieved_document", "tool_output"]
FEATURES = ["instruction_mode", "role_change_request", "hidden_field_name", "side_effect_tool_name",
            "source_stamp_mismatch", "unusual_stamp_distribution", "excessive_length"]
PROFILE = {
    "clean":                [0.06, 0.02, 0.05, 0.03, 0.02, 0.04, 0.05],
    "indirect_instruction": [0.88, 0.42, 0.10, 0.30, 0.35, 0.08, 0.12],
    "hidden_field_request": [0.35, 0.15, 0.86, 0.12, 0.10, 0.06, 0.08],
    "privilege_escalation": [0.30, 0.25, 0.12, 0.84, 0.14, 0.05, 0.07],
    "source_spoofing":      [0.22, 0.55, 0.08, 0.10, 0.82, 0.07, 0.06],
    "corrupted_input":      [0.05, 0.03, 0.04, 0.03, 0.06, 0.80, 0.62],
}
SOURCE_SHARE = {
    "clean":                [0.72, 0.20, 0.08],
    "indirect_instruction": [0.05, 0.70, 0.25],
    "hidden_field_request": [0.55, 0.30, 0.15],
    "privilege_escalation": [0.40, 0.35, 0.25],
    "source_spoofing":      [0.10, 0.60, 0.30],
    "corrupted_input":      [0.65, 0.20, 0.15],
}
TEXT = ["bu ayki fatura tutari gecen aya gore yuksek geldi",
        "sayac endeksi yanlis girilmis olabilir",
        "mahallede kesinti ne zaman bitecek",
        "tarife basamagimi ogrenmek istiyorum",
        "ariza kaydim icin ekip ne zaman gelir",
        "adres degisikligi nasil yapilir"]
RECORDS = []
for i in range(240):
    r = rng(SEED + 6151 * i)
    c = CLASSES[pick(r(), [0.62, 0.10, 0.07, 0.07, 0.07, 0.07])]
    source = SOURCES[pick(r(), SOURCE_SHARE[c])]
    feat = {o: (r() < p) for o, p in zip(FEATURES, PROFILE[c])}
    RECORDS.append({"no": i + 1, "class": c, "source": source, "features": feat,
                    "text": TEXT[int(r() * len(TEXT))],
                    "length": 40 + int(r() * 260)})


def harmful(k):
    return k["class"] != "clean"


WEIGHT = {"instruction_mode": 0.42, "role_change_request": 0.26, "hidden_field_name": 0.44,
          "side_effect_tool_name": 0.40, "source_stamp_mismatch": 0.38,
          "unusual_stamp_distribution": 0.30, "excessive_length": 0.12}
SOURCE_BONUS = {"user": 0.00, "retrieved_document": 0.18, "tool_output": 0.12}


def score(k, seed=SEED):
    p = sum(WEIGHT[o] for o in FEATURES if k["features"][o]) + SOURCE_BONUS[k["source"]]
    r = rng(seed ^ trace_id(k["no"]))
    return min(max(p + (r() - 0.5) * 0.20, 0.0), 1.6)


def model_filter(k, threshold=0.40, seed=SEED):
    return score(k, seed) >= threshold


def approval_gate(k, required=True):
    """A record carrying a side-effect tool request is asked of a human."""
    return required and k["features"]["side_effect_tool_name"]


def measure(records, decision):
    tp = fp = tn = fn = 0
    for k in records:
        flagged, actual = decision(k), harmful(k)
        if flagged and actual:
            tp += 1
        elif flagged and not actual:
            fp += 1
        elif not flagged and actual:
            fn += 1
        else:
            tn += 1
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn,
            "precision": round(precision, 4), "recall": round(recall, 4),
            "false_block_rate": round(fp / (fp + tn), 4) if fp + tn else 0.0}


THRESHOLD, SECOND = 0.50, 20260219             # deployed threshold , second run seed
published = measure(RECORDS, lambda k: model_filter(k, THRESHOLD))
second = measure(RECORDS, lambda k: model_filter(k, THRESHOLD, SECOND))
print(f"published single number: recall {published['recall']:.4f} , in the second run"
      f" {second['recall']:.4f} (band {second['recall'] - published['recall']:.4f})")
print(f"false block rate {published['false_block_rate']:.4f} and"
      f" {second['false_block_rate']:.4f}")
print("\nclass                 records  20260218  20260219  resolution")
class_recall = {}
for c in CLASSES[1:]:
    ks = [k for k in RECORDS if k["class"] == c]
    d = sum(model_filter(k, THRESHOLD) for k in ks) / len(ks)
    d2 = sum(model_filter(k, THRESHOLD, SECOND) for k in ks) / len(ks)
    class_recall[c] = d
    print(f"{c:<20} {len(ks):>8} {d:>9.4f} {d2:>9.4f} {1 / len(ks):>11.4f}")
lowest = min(class_recall, key=class_recall.get)
print(f"difference between the published number and the lowest class:"
      f" {published['recall'] - class_recall[lowest]:.4f} ({lowest})")

# ---- System card: the measures it carries. Every measure not carried remains a CLAIM.
NARROW = {"share of harmful blocked", "false block rate"}
WIDE = NARROW | {"per-class recall", "resolution of the set", "records sent to a human",
                 "covered share of false alarms"}
COMMITMENTS = [
    ("usage limit", "not used in the stated class", "per-class recall"),
    ("usage limit", "irreversible action needs approval", "records sent to a human"),
    ("usage limit", "not the sole basis of a result", "share of harmful blocked"),
    ("transparency item", "share of records wrongly blocked", "false block rate"),
    ("transparency item", "resolution of the measurement", "resolution of the set"),
    ("transparency item", "set the measurement was made on", "set coverage"),
    ("misuse plan", "share covered by appeals", "covered share of false alarms"),
    ("misuse plan", "delay in seeing a degradation", "indicator delay"),
    ("misuse plan", "number of untried classes", "uncovered class"),
]
sent = [k for k in RECORDS if approval_gate(k)]
print(f"\nnumbers added by the wide card: records sent to a human {len(sent)} , of them"
      f" {sum(harmful(k) for k in sent)} harmful ; set {len(RECORDS)} records ,"
      f" recall step {1 / 93:.4f} , false block step {1 / 147:.4f}")
print("\ncommitment class        commitment                           narrow card  wide card")
for kind, what, measure_name in COMMITMENTS:
    print(f"{kind:<23} {what:<36} {'number' if measure_name in NARROW else 'claim':<12}"
          f" {'number' if measure_name in WIDE else 'claim'}")
for name, card_set in (("narrow", NARROW), ("wide", WIDE)):
    present = sum(o in card_set for _, _, o in COMMITMENTS)
    print(f"{name} card: {present}/{len(COMMITMENTS)} commitments tied to a number ,"
          f" {len(COMMITMENTS) - present} remain claims")
```

```
published single number: recall 0.7097 , in the second run 0.7419 (band 0.0322)
false block rate 0.0544 and 0.0544

class                 records  20260218  20260219  resolution
indirect_instruction       24    0.9583    0.9583      0.0417
hidden_field_request       17    0.7059    0.7059      0.0588
privilege_escalation       10    0.8000    0.8000      0.1000
source_spoofing            23    0.8261    0.8261      0.0435
corrupted_input            19    0.2105    0.3684      0.0526
difference between the published number and the lowest class: 0.4992 (corrupted_input)

numbers added by the wide card: records sent to a human 27 , of them 24 harmful ; set 240 records , recall step 0.0108 , false block step 0.0068

commitment class        commitment                           narrow card  wide card
usage limit             not used in the stated class         claim        number
usage limit             irreversible action needs approval   claim        number
usage limit             not the sole basis of a result       number       number
transparency item       share of records wrongly blocked     number       number
transparency item       resolution of the measurement        claim        number
transparency item       set the measurement was made on      claim        claim
misuse plan             share covered by appeals             claim        number
misuse plan             delay in seeing a degradation        claim        claim
misuse plan             number of untried classes            claim        claim
narrow card: 2/9 commitments tied to a number , 7 remain claims
wide card: 6/9 commitments tied to a number , 3 remain claims
```

#### The Remaining Claim

The published single number is 0.7097, and it is hard to call it wrong: it is correct on the set. Per class, the same defense spreads between 0.9583 and 0.2105; the difference between the published number and the weakest class is **0.4992**. "This system is not used in this class" is a usage limit, and the number that would verify it is only on the class row, not inside the average. The narrow card does not carry that row, so the sentence remains a **claim**.

The class row opens a second thing: The two-run band of the published number is 0.7097 to 0.7419, three resolution steps, and the **whole** band comes from the weakest class: corrupted input moves between 0.2105 and 0.3684 while the other four classes show zero difference.

Widening the card ties four more of the nine commitments to a number: the class row, the resolution, the 27 records sent to a human and the covered share of the appeals. The remaining three are covered by no card; that is the state of the measurement, not a flaw of the card. What the **set the measurement was made on** represents no measurement on that set can show. The **delay in seeing a degradation** came out as 45 and 220 records in two runs, so its band is wider than the delay. The **number of untried classes** can only be bounded by counting the tried ones; the share that passed for a class with no row in the report is not zero but unknown.

This is the course rule on the release side: **where the published number is not written together with the set it covers, everything it does not cover is a claim.**

#### Summary

- The published average is 0.7097; per class the same defense spreads between 0.9583 and 0.2105, a difference of 0.4992.
- The average's two-run band is 0.0322, and the whole band comes from the weakest class; the other four show zero difference.
- A class's resolution is the inverse of its record count: 0.1000 for ten records, so even a row written to four digits cannot be read that finely.
- Of the nine commitments the narrow card ties 2 to a number, the wide card 6; the other 3 have no measure: the set's representativeness cannot be measured from inside, the indicator delay's band is wider than the delay, and the untried class's share is unknown.

#### Course Wrap-Up

The numbers measured over the course: The sign scheme is single: **+** shows that the defended setup's number is higher than the baseline, **−** that it is lower, and neither says good or bad. Where rows share no unit, the measure follows the lesson name.

| Lesson | Undefended baseline | Defended setup | Cost |
|---|---|---|---|
| Prompt Injection — recall | no filter 0.0000 | rule filter 0.7957 (**+0.7957**) | false alarms 0 to 23 |
| Jailbreak Techniques — recall | threshold 0.80: 0.4946 | threshold 0.30: 0.9032 (**+0.4086**) | false block rate 0.0068 to 0.1837 |
| Data Exfiltration — requests stopped (35) | no filter 0 | full rule filter 25 (**+25**) | added by masking 0, residue 1 |
| Tool Misuse — harmful sent to a human | no gate 0 | approval gate 24 (**+24**) | 3 unnecessary reviews, recall 0.2581 |
| Training Data Poisoning — harmful share in the corpus | no filter 0.3875 | rule gate 0.1329 (**−0.2546**) | 23 clean records lost |
| Model Inversion and Inference Attacks — hidden fields out | no filter 106 | four-name filter 0 (**−106**) | clean replies losing a field 41 |
| Adversarial Examples — recall on corrupted input | rule filter 0.2632 | rule with added flag 0.8421 (**+0.5789**) | false alarms 23 to 30 |
| AI Red Teaming — share that passes the defense | narrow campaign 0.1000 | 93-trial coverage 0.2043 (**+0.1043**) | selection band 0.0600, untried unknown |
| Input and Output Filters — precision | rule filter 0.7629 | rule and model stacked 0.7551 (**−0.0078**) | false block rate 0.1565 to 0.1633, recall the same |
| Content Moderation — recall on corrupted input | rule filter 0.2632 | content moderation 0.8421 (**+0.5789**) | false block rate 0.1565 to 0.2177 |
| Personal Data Masking — records cleaned | no masking 0 | flag policy 32 (**+32**) | tasks broken 23, 7 for a clean subscriber |
| Permissions and Sandboxing — harmful stopped | undefended 0 | read-only 62 (**+62**) | tasks completed 1.0000 to 0.6667 |
| Untrusted Content Marking — harmful neutralized | no marking 0 | marking 42 (**+42**) | false alarms 0, 1 clean record lost its way |
| Actions Requiring Human Approval — harmful caught | no gate 0 | narrow gate 24 (**+24**) | 3 unnecessary interruptions; reviews 4 at 0.20 |
| Evaluation Set Design — precision (defense fixed) | representative set 0.7629 | borderline sample set 0.4444 (**−0.3185**) | false block rate 0.7143 |
| Automated Evaluation — agreement with human labels | criteria-based 0.8250 | model judge 0.8833 (**+0.0583**) | 0 to 246 calls, 10779 tokens |
| Human Evaluation — agreement between evaluators | three-level scale 0.7583 | guideline with examples 0.9306 (**+0.1723**) | unanimity accuracy 0.9126 to 0.8744 |
| Regression Testing — recall of the judge | format constraint in place 0.9570 | constraint removed 0.7742 (**−0.1828**) | calls 246 to 330, unreadable replies 0 to 31; false block difference exactly 0.0000 |
| Production Monitoring — delay in seeing a degradation | unreported without indicator | proxy reports after 20 records | 32 reviews in 300 records; labeled indicator 45 and 220 |
| The Feedback Loop — covered share of false alarms | channel closed 0.0000 | narrow appeal channel 0.3750 (**+0.3750**) | false alarms 8 to 27 in five rounds |
| Responsible Release — commitments tied to a number (9) | narrow card 2 | wide card 6 (**+4**) | 3 commitments remain claims |

The course's rule is written in the table row by row: **a defense whose blocked share and wrongly blocked share are not written together counts as unmeasured.** With the cost column empty every row would read like a success; it does not, because we wrote it.

**The second claim is read from the same table too: an added defense can make the work worse.** The rows that say so by name: Input and Output Filters (one more clean record blocked, no new harmful one caught), Data Exfiltration (masking adds 0, at the cost of nine deleted fields), Tool Misuse (the source-widened gate takes forty-four clean reviews for nine harmful, precision 0.5586), Training Data Poisoning (the source gate raises clean record loss from 23 to 66 for an unmeasurable 0.0054), Content Moderation (the label falls on nine clean records, the defense stays below the baseline), Personal Data Masking (masking every record breaks 169 tasks), Permissions and Sandboxing (four of the eighteen added records are harmful, false block rate 0.2517), Actions Requiring Human Approval (a wider gate takes the caught from 24 to 17, interruptions to 23) and The Feedback Loop (false alarms 3.4 times, no new harmful one caught). In nine places the added layer made us pay for being added.

**Differences below the resolution are written by name and are not findings:** in Jailbreak Techniques, the one-record difference at threshold 0.30; in Data Exfiltration, the residue that falls to zero in the second run; in Training Data Poisoning, the 0.0054 below the 0.0106 step; in Adversarial Examples, 0.4737 versus 0.4211 inside the class; in Permissions and Sandboxing, one full step of 0.0068; in Actions Requiring Human Approval, the counts 22, 22, 23; in Automated Evaluation, the false block difference of 0.0068 and the class difference of 0.0588; in Production Monitoring, the indicator whose band is wider than its delay. None is zero, none is measured.

This wrap-up also closes **the AI Engineering curriculum.** The measurement axis of the seven courses gathers into one sentence: **surface → prompt → call → retrieval → loop → contract → defense.** Each course started where the previous one left off and wrote the cost of the layer it added. The curriculum's rule one last time: an AI system's number is **not the model's number but the setup's number; a setup whose cost is not written counts as unmeasured.**

Over one hundred and thirty-two lessons the setup was built around the model, each piece with its cost written. One thing was never asked: **the data pipeline that feeds this setup.** The record set was generated inside the lesson, the document vocabulary written by hand, the trace record kept in memory. Where this data comes from, how it runs in production and who learns when it breaks went unasked. The next curriculum, **Data Engineering and MLOps**, builds this pipeline beneath the setup and counts its cost too.

