Skip to content

AI Mail Analyzer

The classifier code in Analyzers/AIMailAnalyzer/ and the Django side that manages its model versions. For how the pieces are deployed and operated see AI / ML Analyzers; for the Django app's models and endpoints see ai_model.

Classifier modules

mail_analysis

MODEL_SPECS = (ModelSpec('safe_suspicious_30_epochs_model.pth', 2, 'main'), ModelSpec('spam_dangerous_30_epochs_model.pth', 2, 'main'), ModelSpec('safe_30_epochs_model.pth', 2, 'sub'), ModelSpec('unwanted_30_epochs_model.pth', 2, 'sub'), ModelSpec('dangerous_30_epochs_model.pth', 4, 'sub')) module-attribute

SUB_MODEL_WIDTHS = (2, 2, 4) module-attribute

ClassificationName

Bases: Enum

SAFE = 0 class-attribute instance-attribute

UNWANTED = 1 class-attribute instance-attribute

DANGEROUS = 2 class-attribute instance-attribute

SubClassificationName

Bases: Enum

INTERNAL = 0 class-attribute instance-attribute

EXTERNAL = 1 class-attribute instance-attribute

SPAM = 2 class-attribute instance-attribute

NEWSLETTER = 3 class-attribute instance-attribute

CLASSIC_PHISHING = 4 class-attribute instance-attribute

CLONE = 5 class-attribute instance-attribute

BLACKMAIL = 6 class-attribute instance-attribute

WHALING = 7 class-attribute instance-attribute

ModelSpec

Bases: NamedTuple

filename instance-attribute

output_dim instance-attribute

group instance-attribute

getMainClassificationProbabilities(device, safe_suspicious_model, spam_dangerous_model, email_embedding)

getMainClassificationInfo(global_probabilities)

getSubClassificationProbabilities(device, models, email_embedding, main_classification_probabilities)

getSubClassificationInfo(global_sub_probabilities)

get_classification_breakdown(global_probabilities)

Label the raw main-classification array by enum name, e.g. {"SAFE": 0.83, "UNWANTED": 0.12, "DANGEROUS": 0.05}.

get_sub_classification_breakdown(global_sub_probabilities)

Label the raw sub-classification array by enum name.

split_into_sentences(mail_body, max_sentences=40)

Split a mail body into sentence-ish chunks for occlusion analysis.

Caps at max_sentences so a pathologically long body can't turn the per-sentence re-embedding pass in get_contributing_phrases into an unbounded number of model calls.

rank_phrase_impacts(sentences, impacts, top_n=5)

Pair sentences with their occlusion impact, keep only sentences whose removal reduced confidence in the predicted class (impact > 0), and return the top_n by impact descending.

get_contributing_phrases(device, safe_suspicious_model, spam_dangerous_model, vectorizer, mail_body, classification_index, baseline_probability, top_n=5)

Sentence-level occlusion: re-embed the body with each sentence removed and measure how much confidence in the predicted class drops. Sentences whose removal drops confidence the most are the ones driving the classification.

mail_archive

Mail-archive helpers (stdlib only).

Everything that needs the ML stack lives in mail_analysis.py, which re-exports these names so existing callers (mail_analysis.untar_file) keep working.

untar_file(filepath, extract_to)

get_header_dict_list(msg)

ResNetMLP

ResNetMLP

Bases: Module

fc1 = nn.Linear(input_dim, input_dim) instance-attribute

fc2 = nn.Linear(input_dim, int(input_dim * 2 / 3)) instance-attribute

fc3 = nn.Linear(int(input_dim * 2 / 3), int(input_dim * 1 / 3)) instance-attribute

fc4 = nn.Linear(int(input_dim * 1 / 3), output_dim) instance-attribute

relu = nn.ReLU() instance-attribute

dropout = nn.Dropout(0.5) instance-attribute

residual_transform = nn.Linear(input_dim, int(input_dim * 1 / 3)) instance-attribute

forward(x)

Inference server

inference_server.server is the FastAPI application; inference_server.v2 is the token-protected /v2 API (/v2/classify, /v2/embed, /v2/train, /v2/reload, health and model endpoints) the ai_model connector calls.

inference_server.server

Persistent inference server for AIMailAnalyzer.

Loads the vectorizer and every model variant ONCE at startup and keeps them resident in memory (a full SentenceTransformer + 5 ResNetMLP checkpoints). The ai_model connector POSTs each mail body to the token-protected /v2 API (v2.py) over suspicious_net, as http://ai_inference:8090.

One variant is loaded: "personal" (models/, trained on this deployment's own labeled mail), the one /v2 classifies with.

Weights stay fresh: the monthly retrain pipeline overwrites models/*.pth in place, so each variant's files are re-checked (mtime) every INFERENCE_RELOAD_CHECK_SECONDS and hot-swapped when they change. POST /reload forces the same check immediately. A file that fails to load (half-copied, corrupt) never replaces a working model.

logger = logging.getLogger('inference_server') module-attribute

VECTORIZER_PATH = os.environ.get('VECTORIZER_PATH', '/worker/AIMailAnalyzer/vectorizers/paraphrase-multilingual-mpnet-base-v2') module-attribute

MODEL_DIRS = {'personal': os.environ.get('MODELS_DIR_PERSONAL', '/worker/AIMailAnalyzer/models')} module-attribute

RELOAD_CHECK_SECONDS = float(os.environ.get('INFERENCE_RELOAD_CHECK_SECONDS', '30')) module-attribute

MAX_CONCURRENCY = max(1, int(os.environ.get('INFERENCE_MAX_CONCURRENCY', '2'))) module-attribute

MAX_MAIL_CHARS = int(os.environ.get('INFERENCE_MAX_MAIL_CHARS', '200000')) module-attribute

OPTIONAL_MODELS = frozenset({'unwanted_30_epochs_model.pth'}) module-attribute

REQUIRED_MODELS = tuple(spec.filename for spec in MODEL_SPECS if spec.filename not in OPTIONAL_MODELS) module-attribute

device = torch.device('cpu') module-attribute

store = ModelStore(MODEL_DIRS) module-attribute

app = FastAPI(title='AIMailAnalyzer inference server', lifespan=lifespan) module-attribute

trainer = JobController(encoder_hash=store.encoder_hash) module-attribute

holder = ModelHolder() module-attribute

Slot

Bases: NamedTuple

One .pth file's state: the live model (None = never loaded), the mtime it was loaded from, and the last load error (cleared on success).

model instance-attribute

mtime_ns instance-attribute

error instance-attribute

ModelStore

Vectorizer + per-variant model slots, with thread-safe hot reload.

model_dirs = model_dirs instance-attribute

vectorizer = None instance-attribute

vectorizer_error = None instance-attribute

vectorizer_path = None instance-attribute

slots = {variant: {spec.filename: Slot(None, None, None) for spec in MODEL_SPECS} for variant in model_dirs} instance-attribute

load_vectorizer(path)

encoder_hash()

encoder_digest of the loaded vectorizer's directory, computed at first use and cached (a load resets it). None while no vectorizer is loaded. Lazy rather than at load, so a start or a test client that never trains or reloads does not read ~1 GB of weights.

refresh(force=False)

(Re)load every model file that is new or changed on disk.

Returns {variant: [filenames reloaded]}. A load failure keeps the previous model (if any) and records the error; the file is retried on the next check because its mtime is only committed on success.

maybe_refresh()

models(variant)

Snapshot {filename: model-or-None}; safe against a concurrent swap.

missing_required(variant)

variant_ready(variant)

unavailable(variant)

The 503 detail for a classify call that cannot be served, else None. Used by the /v2 classify route.

ready()

Serving requires the vectorizer and the "personal" variant.

health_payload()

FairSemaphore

A bounded semaphore served in arrival order. threading.Semaphore lets a thread that releases and asks again take the slot back before a waiting thread wakes, so a request that loops over texts (one slot per text, /v2/embed) could starve a classify. Here a new acquire queues behind the waiters.

free = n instance-attribute

acquire()

release()

lifespan(_app) async

health()

reload_models()

Re-read weights from disk now (e.g. right after the retrain pipeline promotes new ones) instead of waiting for the periodic check.

inference_server.v2

Token-protected /v2 API of the inference server.

logger = logging.getLogger('inference_server.v2') module-attribute

MAX_CLASSIFY_BODY = 32 * 2 ** 20 module-attribute

ACTIVE_VERSION = bundles.LEGACY_VERSION module-attribute

CaseId = Annotated[int, Field(strict=True, ge=0, le=2 ** 63 - 1)] module-attribute

SHADOW_TIMEOUT = float(os.environ.get('AI_SHADOW_TIMEOUT', '8')) module-attribute

ALLOWED_VERSIONS = (['active'], ['active', 'shadow'], ['shadow']) module-attribute

DUAL_BUDGET = 18.0 module-attribute

LIVE_SHADOW_WAIT = 1.0 module-attribute

SHADOW_ONLY_WAIT = 15.0 module-attribute

NO_TOKENIZER = 'token chunking is not available with this encoder' module-attribute

Busy

Bases: Exception

The shadow slot is taken by another shadow pass.

TokenizerUnavailable

Bases: ValueError

ClassifyRequest

Bases: BaseModel

mail_body instance-attribute

versions = ['active'] class-attribute instance-attribute

exclude_case_ids = Field(default_factory=list, max_length=1000) class-attribute instance-attribute

EmbedRequest

Bases: BaseModel

texts = Field(max_length=32) class-attribute instance-attribute

profile instance-attribute

preprocessing = bundles.PREPROCESSING_LEGACY class-attribute instance-attribute

shadow_skip_after()

Seconds of active pass after which the shadow is skipped (10 s with the default 8 s timeout; a timeout of 18 s or more skips the shadow always).

make_v2_router(*, store, classify, slots, max_chars, trainer, holder, shadow_slot=None)

classify is server._classify(models, mail_body), which returns the legacy result dict; slots is the server's concurrency semaphore; trainer is the training_job.JobController (training runs in its own process, outside slots); shadow_slot is the shadow's one-pass lock (a new one when None); holder is the bundles.ModelHolder (the loaded bundles, None = legacy weights).

inference_server.bundles

Bundle reload (POST /v2/reload) and bundle-aware classify (Plan E, Task 6b).

A reload names, per role (active, shadow), a version id, one presigned URL per bundle file (training.bundle.BUNDLE_FILES plus manifest.json) and the manifest's SHA-256 pinned by Django (Ruling E14). Every file is fetched with Task 6's downloader and URL rules (allow-list, no redirects, no proxy, Content-Length required, raw body, the bundle's per-file and total caps checked before anything is written) into a private 0700 directory under its fixed name, then read once with read_bundle, the only bundle reader. The directory is removed on every exit path. Then the server checks what read_bundle cannot know: the manifest's version id is the one asked for, its preprocessing_version is one this server can apply, its input_dim is the loaded encoder's width and, for token chunks, the encoder has a tokenizer. Any failure gives 422 and leaves the current models untouched; both roles load before either is swapped in.

The loaded models live in one tuple (active, shadow), replaced under a lock: a classify reads the tuple once and finishes on the objects it read. active None means the legacy v1 weights in the operator-owned models/ directory (rollback to version 0). shadow is stored and reported but not used by classify yet (Plan F).

logger = logging.getLogger('inference_server.bundles') module-attribute

PREPROCESSING_LEGACY = 'legacy-first128' module-attribute

KNOWN_PREPROCESSING = (PREPROCESSING_LEGACY, PREPROCESSING_VERSION_TOKENS, PREPROCESSING_VERSION_WORDS90) module-attribute

CAMPAIGN_EMBEDDING_VERSION = 'cmp-e1' module-attribute

RELOAD_PREFIX = 'ai-reload-' module-attribute

LEGACY_VERSION = {'id': 'v0', 'preprocessing': PREPROCESSING_LEGACY, 'campaign_embedding_version': CAMPAIGN_EMBEDDING_VERSION, 'calibrated': False} module-attribute

LEGACY_FILE = dict(zip(HEADS, (spec.filename for spec in MODEL_SPECS))) module-attribute

ReloadError

Bases: Exception

A 422 reason: names the role, file or rule, never a URL.

ReloadBusy

Bases: Exception

ReloadFailed

Bases: Exception

An unexpected error while loading: a 500 with a generic body (logged by class name).

BundleSpec

Bases: BaseModel

model_config = ConfigDict(extra='forbid', strict=True) class-attribute instance-attribute

version_id = Field(pattern=JOB_ID_PATTERN) class-attribute instance-attribute

bundle_urls instance-attribute

manifest_sha256 = Field(pattern='^[0-9a-f]{64}$') class-attribute instance-attribute

ReloadRequest

Bases: BaseModel

Both keys are required; null means legacy weights (active) or no shadow.

model_config = ConfigDict(extra='forbid', strict=True) class-attribute instance-attribute

active instance-attribute

shadow instance-attribute

LoadedModel dataclass

version_id instance-attribute

preprocessing_version instance-attribute

manifest_sha256 instance-attribute

heads instance-attribute

knn instance-attribute

calibration instance-attribute

describe()

legacy_models()

The heads keyed as the legacy classify core expects (legacy-first128 bundles).

ModelHolder

transport = transport instance-attribute

tmp_base = tmp_base instance-attribute

state = (None, None) instance-attribute

describe()

reload(req, *, vectorizer, slots, encoder_hash=None)

tier_of(p, cutoffs)

The confidence tier of a calibrated probability: High at or above cutoffs["high"], Medium at or above cutoffs["medium"], else Low (spec section 9).

classify_bundle(model, vectorizer, body, slots, exclude_case_ids=None)

A chunk-mean bundle's answer, in the legacy classify core's dict shape. The heads see the chunk-mean embedding in the bundle's form; the campaign embedding stays the plain first-128 pass (cmp-e1). Main probabilities are training.recipe.predict's calibrated blend; sub-type probabilities are weighted by the main probability as the legacy path does. Class, score and confidence come from the calibrated probabilities through the legacy mapping (a class only at 0.5 or above), and tier from the top one and the bundle's tier_cutoffs. Contributing phrases are not computed (an occlusion pass would cost up to 40 chunked re-embeddings). Raises training.recipe.NoNeighbours when exclude_case_ids leaves no neighbour.

inference_server.training_job

Single-job training controller behind POST /v2/train (Plan E, Task 6).

One job at a time, state in memory only (a restart forgets it; Django then sees 404 and fails the row). Per job, a monitor thread: 1. makes a private 0700 directory (mkdtemp) and downloads the dataset into it; 2. starts python -m inference_server.training_worker <dir> with subprocess.Popen in a new session, an explicit minimal environment (see popen_launcher) and the validated params as JSON on stdin: no URL, no token, no storage key. The worker runs as the same UID, so this keeps secrets out of it by construction but is not a boundary against code running inside it (see training_worker's docstring); 3. reads the worker's progress file, enforces cancel and the wall-time limit (SIGTERM to the worker's process group, then SIGKILL after a grace period), reads the result file; 4. PUTs each bundle file to its own presigned URL, manifest last; 5. removes the directory, on every exit path. Leftovers of a crashed server are swept at start. A recheck job (params.recheck, Ruling F11) runs the gate only: the worker writes no bundle, step 4 is skipped (bundle_urls may be omitted and are ignored) and the report is the gate summary plus the version id rechecked.

URLs (Ruling E9): http or https, no userinfo, no fragment, printable ASCII only, and the host:port (as parsed, the scheme's default port when none is given) must be in AI_BUNDLE_HOSTS. The allow-list is the control: names are compared as strings, nothing is resolved, so an allowed name points wherever the deployment's DNS says. Internationalised host names never pass (fails closed): a non-ASCII URL is refused outright, and for a punycode host (xn--...) httpx reports the decoded name, which differs from urlsplit's, so the parsers disagree; use an ASCII name or an IP address. Redirects are never followed, proxies and .netrc are ignored, the dataset must announce its Content-Length, be at most 200 MB and match it exactly, and is read raw (no content decoding). Errors name the step, never a URL or query string.

logger = logging.getLogger('inference_server.training') module-attribute

JOB_ID_PATTERN = '^[A-Za-z0-9_-]{1,64}$' module-attribute

JOB_ID_RE = re.compile(JOB_ID_PATTERN) module-attribute

LABEL_PATTERN = '^[A-Za-z0-9._:/+-]{1,128}$' module-attribute

UPLOAD_NAMES = (*BUNDLE_FILES, MANIFEST) module-attribute

MAX_DOWNLOAD = 200 * 2 ** 20 module-attribute

MAX_BODY = 64 * 1024 module-attribute

MAX_URL = 4096 module-attribute

MAX_RESULT = 1024 * 1024 module-attribute

MAX_JOBS_KEPT = 50 module-attribute

TMP_PREFIX = 'ai-train-' module-attribute

TERMINAL = frozenset({'succeeded', 'failed', 'cancelled'}) module-attribute

DEFAULT_PORTS = {'http': 80, 'https': 443} module-attribute

TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=60.0, pool=5.0) module-attribute

Label = Field(pattern=LABEL_PATTERN) module-attribute

MAX_ENCODER_BYTES = 4 << 30 module-attribute

WORKER_MODULE = 'inference_server.training_worker' module-attribute

PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) module-attribute

THREAD_ENV = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMEXPR_NUM_THREADS') module-attribute

WORKER_LOG = 'worker.err' module-attribute

RedactUrls

Bases: Filter

Rewrites a record whose message or traceback holds a URL query string or an X-Amz-* parameter. Never drops a record.

filter(record)

JobError

Bases: Exception

A failure reason safe to store and return: never a URL, query string or data.

TrainParams

Bases: BaseModel

Everything the worker gets besides the dataset. Strings land in the manifest. The server adds encoder_hash itself (encoder_digest of its own encoder); a caller cannot send one.

model_config = ConfigDict(extra='forbid', strict=True) class-attribute instance-attribute

version_id = Field(pattern=JOB_ID_PATTERN) class-attribute instance-attribute

seed = Field(ge=0, le=2 ** 31 - 1) class-attribute instance-attribute

repeats = Field(default=3, ge=1, le=5) class-attribute instance-attribute

epochs = Field(default=30, ge=1, le=100) class-attribute instance-attribute

baseline instance-attribute

preprocessing_version = PREPROCESSING_VERSION_TOKENS class-attribute instance-attribute

parent_version = Field(default=None, pattern=JOB_ID_PATTERN) class-attribute instance-attribute

ledger_digest = Label class-attribute instance-attribute

campaign_refs_digest = Label class-attribute instance-attribute

encoder_id = Label class-attribute instance-attribute

trainer_revision = Label class-attribute instance-attribute

campaign_profile instance-attribute

recheck = False class-attribute instance-attribute

TrainRequest

Bases: BaseModel

model_config = ConfigDict(extra='forbid', strict=True) class-attribute instance-attribute

job_id = Field(pattern=JOB_ID_PATTERN) class-attribute instance-attribute

dataset_url = Field(max_length=MAX_URL) class-attribute instance-attribute

bundle_urls = None class-attribute instance-attribute

params instance-attribute

PopenHandle

multiprocessing.Process-like view of the worker. Signals go to its whole process group (start_new_session=True made it the leader), so anything it started dies with it. exitcode is negative for a signal, like multiprocessing's.

proc = proc instance-attribute

exitcode property

is_alive()

join(timeout=None)

terminate()

kill()

Job dataclass

job_id instance-attribute

state = 'queued' class-attribute instance-attribute

progress = 0.0 class-attribute instance-attribute

report = None class-attribute instance-attribute

error = None class-attribute instance-attribute

cancel = field(default_factory=threading.Event) class-attribute instance-attribute

started = field(default_factory=time.monotonic) class-attribute instance-attribute

view()

JobController

launcher = launcher instance-attribute

encoder_hash = encoder_hash or (lambda: None) instance-attribute

transport = transport instance-attribute

tmp_base = tmp_base instance-attribute

grace_seconds = grace_seconds instance-attribute

poll_seconds = poll_seconds instance-attribute

jobs = {} instance-attribute

base_dir()

start(req, encoder_hash=None)

encoder_hash: the caller computes it (self.encoder_hash) outside the event loop.

get(job_id)

cancel(job_id)

redact(text)

install_log_redaction()

Idempotent: the filter on the httpx/httpcore loggers and on the root handlers present now (called at import and again when a job starts, after the server configured logging).

encoder_digest(path, *, cap=MAX_ENCODER_BYTES)

SHA-256 over every regular file under path (symlinks are not followed), in sorted relative POSIX name order: name, NUL, size, NUL, bytes. None when the path is missing or unreadable or the files exceed cap. The training job records it as the manifest's encoder_hash and reload compares it, so a bundle trained with another encoder is refused.

allowed_hosts()

AI_BUNDLE_HOSTS: comma separated host:port entries; anything else is ignored. Empty or unset refuses every URL.

check_url(url, hosts, what)

Validate with both urlsplit and httpx (the parser that sends the request): they must agree on scheme, host and port, so a parser trick cannot pass one check and reach another host.

worker_env(workdir, threads, max_memory_mb)

The worker's whole environment. Nothing is inherited from the server (no token, no storage or database secret, no proxy, no PYTHONPATH or LD_PRELOAD); TMPDIR keeps scratch files inside the job directory, which is removed at the end.

popen_launcher(workdir, params, threads, max_memory_mb)

Default launcher. No shell; the module name is a constant and the params travel as JSON on stdin (at most training_worker.MAX_PARAMS bytes); stdout and stderr go to a 0600 file in the job directory.

default_base_dir()

Where job and reload directories go: AI_TRAIN_TMPDIR, else the system temp dir (read at each use, not at import).

sweep(base, prefix)

Remove leftovers named prefix* under base: only real directories owned by this uid, never through a symlink. Call it only where no live directory of that prefix can exist.

make_client(transport=None)

The only HTTP client for presigned URLs: no redirects, no proxy or .netrc from the environment, short timeouts.

download(client, url, path, *, cap, what, check=lambda: None)

GET url (an httpx.URL from check_url) into a new 0600 file at path. The response must be a 200 with an identity encoding and a Content-Length of at most cap, and the raw body must match it exactly; the cap is also enforced while streaming. check() runs after each chunk (cancel, deadline). Errors are JobError naming what, never the URL.

Training

The recipe, bundle format and promotion gate the training job runs.

training.recipe

The retrain recipe (spec section 5), ported from the test bed (lib_spike.py, exp_e_combined.py, exp_a2_calibrate.py): five ResNetMLP heads with class weights only. The main decision is the mean of two components, the head hierarchy and a distance-weighted cosine 5-NN vote, each smoothed (eps 0.05) and calibrated by its own temperature, as in exp_e_combined.py (Ruling E12).

HEADS = ('safe_suspicious', 'spam_dangerous', 'safe', 'unwanted', 'dangerous') module-attribute

NUM_OUT = {'safe_suspicious': 2, 'spam_dangerous': 2, 'safe': 2, 'unwanted': 2, 'dangerous': 4} module-attribute

DANGEROUS_ORDER = {'2_CLASSIC_PHISHING': 0, '2_CLONE_PHISHING': 1, '2_BLACKMAILING_PHISHING': 2, '2_WHALING_PHISHING': 3} module-attribute

SUB_HEADS = ('safe', 'unwanted', 'dangerous') module-attribute

K = 5 module-attribute

TRUNCATION_LIMIT = 0.05 module-attribute

PREPROCESSING_VERSION_WORDS90 = 'chunkmean-words90-v1' module-attribute

PREPROCESSING_VERSION_TOKENS = 'chunkmean-tokens-v1' module-attribute

PREPROCESSING_VERSION = PREPROCESSING_VERSION_TOKENS module-attribute

EPS = 0.05 module-attribute

T_BOUNDS = (0.2, 30) module-attribute

COMPONENTS = ('hierarchy', 'knn') module-attribute

Heads dataclass

models instance-attribute

input_dim instance-attribute

NoNeighbours

Bases: ValueError

The neighbour index is empty, or exclude_case_ids left no row.

Knn dataclass

embeddings instance-attribute

labels instance-attribute

case_ids = None class-attribute instance-attribute

build(X, folders, case_ids=None) classmethod

vote(X, *, exclude_case_ids=None)

Distance-weighted cosine 5-NN class probabilities (sklearn's weights="distance": 1/d, and exact matches take the whole vote). exclude_case_ids: rows whose case id is listed are left out (a case already in the ledger is not its own neighbour); ignored for an index without case ids.

Predictions dataclass

main instance-attribute

hierarchy instance-attribute

knn instance-attribute

logits instance-attribute

sub instance-attribute

head_target(head, folder)

Target class of a folder for a head, None when the head does not use the row.

main_class(folder)

0 Safe, 1 Unwanted, 2 Dangerous: the folder's digit prefix.

chunk_by_tokens(vectorizer, text, *, max_chunks=8)

Greedy word chunks that fit the encoder window (max_seq_length, special tokens included). A single word longer than the window gets a chunk of its own (the encoder truncates it).

chunk_mean_embedding(vectorizer, text, *, words=90, max_chunks=8, by_tokens=True)

Mean of the embeddings of the text's chunks, at most max_chunks of them.

Default (by_tokens=True, Ruling E16): chunks sized to the encoder window, PREPROCESSING_VERSION_TOKENS. by_tokens=False: 90-word chunks, PREPROCESSING_VERSION_WORDS90, the form the spec's evidence was measured on. The caller records the matching version in the manifest.

truncation_rate(vectorizer, texts, words=90)

Share of word chunks whose token count (special tokens included) exceeds the window.

fit_heads(X, folders, *, seed, epochs=30, threads=2)

Train the five heads on the rows each head uses. Class weights only, no resampling. The minimum-samples rule is the gate's (check 4); here a head without rows is an error.

smooth(P, eps=EPS)

exp_e_combined.smooth: mix with the uniform distribution, so a one-hot vote has finite logits.

predict(heads, knn, X, *, temperature=None, exclude_case_ids=None)

temperature: {"hierarchy": T, "knn": T}; None means 1.0 for both (uncalibrated). exclude_case_ids is passed to Knn.vote.

fit_temperature(logits, labels)

One temperature minimising the NLL of softmax(logits / T) over T_BOUNDS (Ruling E5). Fit it on held-out groups only. T > 0, so the argmax never changes.

fit_temperatures(predictions, labels)

One temperature per component (Ruling E12), from held-out predictions.

temperature_at_bound(t)

True when a fitted temperature sits on a search bound (the fit is then not trustworthy).

training.bundle

The model bundle (spec section 3): five heads, calibration, kNN index, campaign profile and a manifest holding a SHA-256 per file. The bytes come from object storage through a presigned URL, so they are untrusted: read_bundle reads every file once into memory, verifies all hashes, and only then parses the same bytes (no TOCTOU). Heads are npz files (Ruling E15: no pickle and no torch deserialiser on bundle bytes, because torch's zip reader and Python's disagree on archive layout): float32 arrays that must match a freshly built ResNetMLP key for key and shape. Errors are a single BundleError naming the file or field, never file contents.

MANIFEST = 'manifest.json' module-attribute

BUNDLE_FILES = (*(f'heads/{h}.npz' for h in HEADS), 'calibration.json', 'knn_index.npz', 'campaign_profile.json') module-attribute

MAX_TOTAL = 100 * 2 ** 20 module-attribute

MAX_FILE = 50 * 2 ** 20 module-attribute

JSON_CAPS = {MANIFEST: 512 * 1024, 'calibration.json': 64 * 1024, 'campaign_profile.json': 256 * 1024} module-attribute

MAX_JSON_DEPTH = 32 module-attribute

MAX_JSON_NODES = 20000 module-attribute

MAX_ZIP_MEMBERS = 64 module-attribute

ZIP_SLACK = 64 * 1024 module-attribute

TEMPERATURE_RANGE = tuple(float(x) for x in T_BOUNDS) module-attribute

MAX_INPUT_DIM = 4096 module-attribute

TEMPERATURE_KEYS = ('hierarchy', 'knn') module-attribute

MANIFEST_FIELDS = ('version_id', 'recipe_id', 'preprocessing_version', 'encoder_id', 'encoder_hash', 'ledger_digest', 'campaign_refs_digest', 'metrics', 'gate', 'parent_version', 'trainer_revision', 'input_dim') module-attribute

MAX_CD_BYTES = 64 * 1024 module-attribute

BundleError

Bases: Exception

Bundle dataclass

heads instance-attribute

calibration instance-attribute

knn instance-attribute

campaign_profile instance-attribute

manifest instance-attribute

input_dim instance-attribute

write_bundle(dir, *, heads, calibration, knn, campaign_profile, manifest)

Write a bundle atomically: build it in a sibling temp dir, then rename. Hashes are computed here. Returns the SHA-256 hex of manifest.json (Ruling E14: the caller pins it). Notes: an existing empty directory at the target is replaced, a non-empty path is refused, a missing parent raises BundleError, and the call may raise (OSError) after the bundle is in place if the final directory fsync fails.

read_file_capped(dir_fd, name, rel, spent)

Read one regular, single-link file from an open directory; return (bytes, new total).

json_shape_ok(v)

Depth and node-count bound for parsed JSON (iterative).

npz_shapes(data, rel, max_len)

Stored members only, laid out as np.savez writes them, and every array header exactly consistent with its member size, all checked before any array is read: a deflate bomb, a directory flood or a lying header is never inflated or allocated. Returns {array name: (shape, dtype)}.

read_bundle(dir, *, manifest_sha256)

Ruling E14: manifest_sha256 (pinned by the caller, never read from the bundle) is compared to the manifest bytes before anything is parsed.

training.gate

The gate (spec section 5): six offline checks a candidate must pass before shadow.

Held-out protocol, as in the test bed (exp_e_combined.py, exp_a2_calibrate.py): per seed, 5-fold StratifiedGroupKFold on the near-duplicate groups gives out-of-fold component logits; the temperatures are fitted on a random half of the groups and applied to the other half (and the other way round), so no row is scored by a calibration fitted on its own group. The baseline is the active recipe retrained on the same folds (not the active weights, which may have seen the mails). A recipe is a callable recipe(X, folders, train_idx, test_idx, *, seed) returning {component: (len(test_idx), 3) logits}; the components are calibrated and averaged. A recipe may add RESAMPLING_SKIPPED: [head, ...] (legacy_recipe does), which the report collects.

Check 4 counts head samples on the whole dataset, not per training fold; when it fails nothing is trained. Check 5 is measured cross-half (Ruling E13): the High cutoff chosen on one calibration half, by the same rule as the stored cutoff (Ruling E17), is scored on the other. Wilson intervals use per-repeat mean counts (pooled / repeats).

N_SPLITS = 5 module-attribute

MIN_HEAD_SAMPLES = 8 module-attribute

DANGEROUS_SAFE_TOLERANCE = 0.01 module-attribute

MACRO_F1_TOLERANCE = 0.02 module-attribute

MAX_ECE = 0.12 module-attribute

HIGH_PRECISION = 0.9 module-attribute

HIGH_CUTOFF_PRECISION = 0.95 module-attribute

HIGH_CUTOFF_FLOOR = 0.85 module-attribute

MEDIUM_CUTOFF = 0.6 module-attribute

TIER_CUTOFF_KEYS = ('high', 'medium') module-attribute

HIGH_MIN_SHARE = 0.05 module-attribute

RESAMPLING_SKIPPED = 'resampling_skipped' module-attribute

HIGH_UNREACHABLE = 1.01 module-attribute

CLASSES = ('Safe', 'Unwanted', 'Dangerous') module-attribute

CHECKS = ('dangerous_called_safe', 'macro_f1', 'calibration_error', 'head_samples', 'high_tier_precision', 'campaign_dangerous_called_safe') module-attribute

GateReport dataclass

checks instance-attribute

seeds instance-attribute

counts instance-attribute

per_class = field(default_factory=list) class-attribute instance-attribute

confusion_matrix = field(default_factory=list) class-attribute instance-attribute

tiers = field(default_factory=list) class-attribute instance-attribute

tier_cutoffs = field(default_factory=dict) class-attribute instance-attribute

campaign = field(default_factory=dict) class-attribute instance-attribute

baseline = field(default_factory=dict) class-attribute instance-attribute

candidate = field(default_factory=dict) class-attribute instance-attribute

fallback = False class-attribute instance-attribute

temperature_at_bound = field(default_factory=dict) class-attribute instance-attribute

temperatures = field(default_factory=list) class-attribute instance-attribute

high_tier = field(default_factory=dict) class-attribute instance-attribute

pooled_repeats = 1 class-attribute instance-attribute

note = '' class-attribute instance-attribute

legacy_resampling_skipped = field(default_factory=list) class-attribute instance-attribute

eligible property

wilson(successes, n, z=1.96)

Wilson score interval for successes out of n (Ruling E4); (0, 1) when n is 0. Counts may be per-repeat means (pooled / repeats), hence floats.

ece(probs, labels, bins=15)

Binned calibration error (Ruling E6): equal-width bins (lo, hi] on the top-class probability, |confidence - accuracy| weighted by the bin's share of rows.

head_class_counts(folders)

{head: [rows per class]} over the whole dataset.

build_checks(*, dangerous_safe, baseline_dangerous_safe, macro_f1, baseline_macro_f1, ece, head_counts, high_tier, campaign_dangerous_safe, baseline_campaign_dangerous_safe)

candidate_recipe(X, folders, train_idx, test_idx, *, seed, epochs=30, threads=2)

The retrain recipe (training/recipe.py): head hierarchy and 5-NN vote logits.

legacy_recipe(X, folders, train_idx, test_idx, *, seed, epochs=30, threads=2)

Version 0's recipe (the test bed's "asis"): SMOTE + random oversampling then class weights per head, head hierarchy only (no neighbour vote). A head that SMOTE cannot resample trains without it, as the legacy code did, and is listed under RESAMPLING_SKIPPED. Use it as baseline_recipe while version 0 is active. Needs imbalanced-learn, which the inference image does not ship.

calibrate_halves(logits, labels, groups, *, seed)

Calibrated blend of the components: temperatures fitted on a random half of the groups score the other half, and the other way round. Returns (probs, [temperatures per half], in_a: the boolean membership of the first half).

high_tier_cross_half(probs, labels, in_a)

Ruling E13: the High cutoff chosen on one half's calibrated predictions (by the stored cutoff's rule, Ruling E17), counted on the other half, both ways. unreachable when a half has no cutoff; floored when the 0.85 floor decided a half's cutoff.

run_gate(X, folders, groups_dup, groups_campaign, *, baseline_recipe, recipe=candidate_recipe, repeats=3, seed0=0)

Run the six checks (spec section 5) over repeats seeds of 5 grouped folds (Ruling E7). baseline_recipe is the active version's recipe (legacy_recipe for version 0); both recipes are trained on the same train rows. Metrics are averaged over seeds; the confusion matrix and the tiers hold counts pooled over seeds (each mail counted once per repeat), and Wilson intervals use the per-repeat mean counts (Ruling E7).

training.dataset

The training dataset (Task 8's ledger export), downloaded by the server through a presigned URL, so untrusted bytes. An uncompressed npz with exactly four arrays, one row per ledger mail: embeddings (float32 (n, d), the classifier input, token chunk-mean), campaign_embeddings (float32 (n, d2), the first-128 cmp-e1 vector that the groups are built from, Ruling E18), folders (fixed-width unicode, one of FOLDERS), case_ids (int64, unique, except NO_CASE, which marks a row that matched no case and may repeat) and ties (int64 (k, 2), row index pairs of the same campaign from CampaignMember, k <= MAX_ROWS, possibly empty; the campaign groups join them, Ruling E24). No mail text. The archive goes through bundle.py's checks (stored members only, layout as np.savez writes it, every array header consistent with its member) before np.load(allow_pickle=False) reads it.

MAX_DATASET_BYTES = 200 * 2 ** 20 module-attribute

MAX_ROWS = 8000 module-attribute

MAX_FOLDER_CHARS = 64 module-attribute

FOLDERS = ('0_LEGIT_INTERNAL_COMMUNICATION', '0_LEGIT_EXTERNAL_COMMUNICATION', '1_SPAM', '1_NEWSLETTER', '2_CLASSIC_PHISHING', '2_CLONE_PHISHING', '2_BLACKMAILING_PHISHING', '2_WHALING_PHISHING') module-attribute

NO_CASE = -1 module-attribute

ARRAYS = ('embeddings', 'campaign_embeddings', 'folders', 'case_ids', 'ties') module-attribute

DatasetError

Bases: Exception

Names the array or the rule, never the data.

Dataset dataclass

embeddings instance-attribute

campaign_embeddings instance-attribute

folders instance-attribute

case_ids instance-attribute

ties instance-attribute

read_dataset(path)

Model registry (Django)

The ai_model app: the registry is the only code that changes a model version's status; promotion enforces the conditions; retrain drives the training job.

ai_model.registry

The model registry. Every status change goes through these functions and writes a ModelEvent.

One previous version only: when a version becomes active, the old active becomes previous and an older previous becomes discarded. Exactly one active: enforced by the unique ModelVersion.active_slot column (MariaDB ignores partial unique indexes) plus locking reads.

BUCKET = 'ai-models' module-attribute

DATASET_BUCKET = 'ai-datasets' module-attribute

PRESIGN_EXPIRY = timedelta(minutes=15) module-attribute

BUNDLE_FILE_NAMES = ('heads/safe_suspicious.npz', 'heads/spam_dangerous.npz', 'heads/safe.npz', 'heads/unwanted.npz', 'heads/dangerous.npz', 'calibration.json', 'knn_index.npz', 'campaign_profile.json', 'manifest.json') module-attribute

logger = logging.getLogger(__name__) module-attribute

MAX_SHADOW_MIN_DAYS = 365 module-attribute

V0_MANIFEST = {'version_id': 'v0', 'preprocessing_version': 'legacy-first128', 'source': 'legacy weights directory'} module-attribute

RegistryError

Bases: Exception

StateChanged

Bases: RegistryError

The version is no longer in the state the caller saw (a stale screen).

bundle_prefix_for(version_id, job_id)

ensure_bucket()

Create the bucket if absent (there is no deployment-time bootstrap for buckets). Callers that presign PUT urls (the retrain start) must call this first.

dataset_key_for(job_id)

One dataset per training job, in DATASET_BUCKET.

ensure_dataset_bucket()

ensure_bucket for DATASET_BUCKET.

create_candidate(version_id, bundle_prefix, manifest, manifest_sha256, campaign_profile, trigger, user, job_id, parent=None)

set_active(version, user=None, note='')

candidate, shadow, previous (rollback) and a discarded version 0 can become active. The old active becomes previous, an older previous becomes discarded (one previous only).

promote_shadow(version, user, note, check)

set_active for a promotion (ai_model.promotion). check(locked_target) runs under the row locks and raises to refuse. Returns (old_active, old_previous) as they were, for revert_promote.

revert_promote(target, old_active, old_previous, user=None, note='')

Undo promote_shadow after the server refused the reload: target back to shadow (to candidate if another version became the shadow meanwhile), old_active back to active, old_previous (discarded by set_active) back to previous. All rows are checked first: if any moved meanwhile nothing is written. True when reverted.

rollback_to_previous(user, note='', expected_id=None)

The previous version becomes active again, picked under the row locks. Returns (target, old_active), or None when there is no previous. With expected_id, a previous with another id raises StateChanged.

revert_rollback(target, old_active, user=None, note='')

Undo rollback_to_previous: a swap back, only if both rows are where it left them.

extend_shadow(version, days, user=None, note='')

Raise the shadow's minimum period (shadow_min_days); it never goes down.

discard(version, user=None, note='', expected_status=None)

With expected_status, a version in another status raises StateChanged.

set_shadow(version, user=None, note='')

A gate-eligible candidate becomes the one shadow; its seeding (Ruling F8) is queued once the change commits.

shadow_refused(version)

serversync: the server refused the shadow's bundle and runs without it. One event per run of refusals (the beat retries every 300 s); the version stays the shadow.

unshadow(version, user=None)

The server refused a shadow just started: back to candidate, its shadow period cleared, so an admin may start it again. A version that is no longer the shadow is left as it is.

recheck_started(version, user, job_id)

Ruling F11: a gate recheck of this version was posted (retrain.start_recheck).

retrain_requested(version, user, note)

A manual retrain was started (api retrain view); version is the job's parent.

flag_resolved(user, note)

A flagged label was resolved (api flag view).

record_breaker_reset(user, note)

An admin reset the ai_model circuit breaker (api breaker view).

review_recorded(version, user, note)

A shadow review was appended (promotion.review, inside its transaction).

record_recheck(version, job_id, passed, finished_at)

Store a finished recheck as {job_id, passed, finished_at (ISO, UTC)}. Only the shadow version takes one: a version discarded or promoted meanwhile is left as it is.

fail(version, error, user=None)

ensure_version_zero(user=None)

Version 0 is the legacy weights: a row without a bundle (Ruling E19). Idempotent. It is active when nothing is, else previous when no previous exists, else discarded; set_active accepts a discarded v0, so it stays a rollback target.

reload_payload(active, shadow=None)

Body for /v2/reload. Version 0 (no bundle) is active: null; no shadow is shadow: null.

ai_model.promotion

Promote, roll back, extend and review shadow versions (Rulings F12, F13).

can_promote is the only place the promotion conditions live; promote re-evaluates them under the row locks, so whatever a client sent, an unmet condition refuses the promotion. Pointer moves go through registry.set_active; the server follows after commit (serversync, Ruling F2).

logger = logging.getLogger(__name__) module-attribute

S = ModelVersion.Status module-attribute

E = ShadowCampaignEffect.Effect module-attribute

SHADOW_MIN_RESULTS = 500 module-attribute

DEFAULT_MIN_DAYS = 7 module-attribute

WINDOW_DAYS = 15 module-attribute

RECHECK_MAX_AGE_DAYS = 7 module-attribute

PromotionRefused

Bases: Exception

codes = list(codes) instance-attribute

critical_flips(version)

Live results whose would-be verdict flips Safe/Dangerous (Ruling F6).

alert_delta(version)

Ruling F9, from the cases' current campaign membership.

snapshot(version)

The review items read once: {"critical": [ShadowResult], "delta": alert_delta}. Callers that need several of can_promote, latest_reviews and the lists pass one snapshot around.

latest_reviews(version, snap=None)

{(kind, case_id): ShadowReview}, the latest review per item, current items only.

can_promote(version, snap=None)

Unmet conditions by stable code; empty when the version may be promoted. Reads the fields of the object given (promote passes the locked row).

promote(version, user, note='')

Ruling F13: conditions re-checked under the row locks, then set_active and a server sync.

rollback(user, note='', expected_id=None)

The previous version becomes active again; PromotionRefused(["no_previous"]) without one, PromotionRefused(["state_changed"]) when expected_id names another version.

start_shadow(version, user, note='')

registry.set_shadow, then a server sync that must load the shadow. A refusal (4xx) puts the version back to candidate and raises ReloadRefused; a transient failure keeps the shadow and the beat heals the server.

discard(version, user, note='', expected_status=None)

registry.discard, then a best-effort server sync (a discarded shadow leaves the server). PromotionRefused(["state_changed"]) when the version is no longer in expected_status.

extend(version, days, user, note='')

review(version, kind, case_id, user, note='')

Append a review of one current flip or alert-delta item of the shadow; refuses an item that does not exist and a version that is not the shadow.

ai_model.retrain

Retrain orchestration (Plan E, Task 8b): export the ledger, hand the inference server presigned URLs through POST /v2/train, poll GET /v2/train/{job_id} with short Celery tasks (ai_model.tasks.poll_retrain) and register a succeeded job as a candidate through the registry. Nothing here activates or reloads a version (Plan F promotes). A gate recheck of the shadow (start_recheck, Ruling F11) uses the same chain and running slot but has no bundle and never creates a candidate: its result goes on ModelVersion.recheck through the registry.

A RetrainJob row is written before the server is called; a failed, cancelled, timed-out or half-uploaded job registers nothing and its bundle prefix and dataset are deleted. Presigned URLs go into the request body only: never logged, stored or printed. Errors are stored as class names, or, for a job the server failed, as the server's own short reason filtered by server_error.

logger = logging.getLogger('ai_model.retrain') module-attribute

J = RetrainJob.State module-attribute

POLL_SECONDS = 30 module-attribute

MAX_AGE = timedelta(hours=6) module-attribute

STOP_MARGIN = timedelta(minutes=30) module-attribute

BACKOFF = timedelta(days=7) module-attribute

UPLOAD_EXPIRY = timedelta(hours=4, minutes=15) module-attribute

ENCODER_ID = 'sentence-transformers/paraphrase-multilingual-mpnet-base-v2' module-attribute

TRAINER_REVISION = 'plan-e-candidate-v1' module-attribute

GATE_KEYS = ('checks', 'candidate', 'baseline', 'campaign', 'tier_cutoffs') module-attribute

ERROR_MAX = 200 module-attribute

RetrainRefused

Bases: Exception

The retrain did not start; the message says why (no URL, no mail text). cause: the class name of an unexpected error behind it, if any.

cause = cause instance-attribute

JobRunning

Bases: RetrainRefused

Another retrain job holds the running slot.

IncompleteUpload

Bases: Exception

server_error(text)

The server's error of a failed job, kept for the operator: only [A-Za-z0-9 _.:,()/=-], at most ERROR_MAX characters; anything holding a URL ("://") or a query ("?") is dropped whole. The server's reasons name fields, heads and classes (e.g. the head floor), never mail text.

running_job()

start_retrain(user=None, trigger=ModelVersion.Trigger.MANUAL)

Start one training job. RetrainRefused when the connector is disabled, a job is running, the export refuses (its message passes through) or the server refuses the request.

start_recheck(version, user=None)

Ruling F11: re-run the gate of the shadow version on the current ledger with its recipe and seed (params.recheck: no final fit, no bundle). Refused like start_retrain, and for a version that is not the shadow. The poll stores {job_id, passed, finished_at} on ModelVersion.recheck; a failed or cancelled recheck changes nothing on the version.

poll_once(job_id)

Ask the server once and settle the job if it ended. Returns the job's state after. The server's answer comes first: a job it finished is registered even when polled after MAX_AGE; MAX_AGE only ends a job that is still running (or that nobody can ask about).

give_up(job, error)

Fail a job whose poll chain keeps raising, past MAX_AGE + STOP_MARGIN (poll_retrain).

poll_countdown()

Seconds before the next poll: at least POLL_SECONDS, and longer than the client timeout, so a slow POST /v2/train is not answered 404 by a poll that overtook it.

is_recheck(job)

cancel_retrain()

Ask the server to cancel the running job; the poll records the outcome. None if no job.

interval_days(config)

newest_version()

The newest candidate, shadow, active or previous version: the age the schedule counts from.

maybe_start()

The daily check: start a retrain when retrain_enabled is true, no job runs and the newest candidate, shadow, active or previous version is older than retrain_interval_days. Backoff: a scheduled job that failed (or a scheduled start that was refused, recorded as a failed job with error "Refused") within BACKOFF stops the check until it is older.

ai_model.ledger

The label ledger writers (Plan D). Append-only: a row is never updated except its status, flag_reason and resolution fields (rulings D4, D5), and, through replace_embedding only, the classifier embedding and its preprocessing version (Plan E, Task 8a).

MISMATCH = 'folder_verdict_mismatch' module-attribute

CONFLICT = {'Dangerous': 'Safe', 'Safe': 'Dangerous'} module-attribute

MIX_CLASSES = ('Dangerous', 'Safe', 'Unwanted') module-attribute

FolderObservation dataclass

A ReviewMail with its key, class, matched case and snapshot, computed before the write.

mail instance-attribute

message_id instance-attribute

message_hash instance-attribute

label_class instance-attribute

case instance-attribute

snapshot instance-attribute

enabled()

Ruling D6: verdict and challenge rows only while the ai_model connector is enabled.

is_observed(mail)

prepare_folder_observation(mail)

The read-only pre-pass for a ReviewMail: None when the folder has no known class or the message is already observed there, else its key, matched case and snapshot.

record_folder_observation(observation, vectors, *, preprocessing_version, embedding_version)

Append the folder row; None when a concurrent write got there first (re-checked here).

vectors is {"classifier": [float, ...], "campaign": [float, ...]}. The new row supersedes every other non-superseded row of the message (active or flagged, all in other folders since this one has none; ruling D5): a move is the analysts' newer label, so a flag on the old folder row is moot and the new row is conflict-checked again. It is written flagged when it is a hard conflict with the active verdict. Runs in a transaction (savepoint inside the sync's chunk).

attach_case(record_pk, case)

Late case match (the backfill): set the case of a folder row that matched none, and run the live conflict check against that case's active verdict in the same transaction, so a hard conflict flags the row. False when the row got a case meanwhile.

record_verdict(case, user, label_class, *, observed_at=None)

Append the analyst's verdict; None for the same class as the case's active verdict row. A different class supersedes that row; a hard conflict flags the folder rows (ruling D6).

Deliberate: a result outside Safe, Unwanted, Dangerous, Suspicious and Inconclusive (Unchallenged, AllowListed, Failure) is not a label. It stores nothing and does not supersede the earlier verdict row, which stays the case's last analyst label. observed_at (default now) lets the backfill date a row at the case's last update.

record_challenge(case, proposed)

Append the reporter's proposal (trust proposal, never trainable, never flags), superseding the case's earlier active proposal; None for a blank or unknown proposal.

resolve_flag(record, user, note, *, keep)

Close a flag: keep makes the row active again, else it is superseded. flag_reason stays as the audit trail (ruling D4). The row is re-read under a row lock, so a stale instance or a second resolver gets ValueError. With keep, any other active folder row of the message is superseded and carries the same resolution, naming this row.

replace_embedding(record, vector, preprocessing_version)

The one exception to append-only, for ai_model_reembed_ledger: a folder row's classifier embedding re-computed from the same mail in another preprocessing. Changes embedding and preprocessing_version only, and only while the row still has the version the caller read (False otherwise: changed meanwhile). LabelRecord.save() still refuses both fields. ValueError unless vector is ENCODER_WIDTH finite floats (the message names no value).

ledger_summary()

Counts of the current rows (superseded ones are history): {source: {class: n}}, the flagged count and the trainable count. Classes stay per source, as a folder Safe and a proposed Safe are not the same kind of label.

label_mix(now=None)

Share of each class among the active folder rows now and among those observed more than MIX_WINDOW_DAYS ago. jump names the class whose share moved most, when that is more than MIX_JUMP, when both windows hold at least MIX_MIN_ROWS rows; else None.