Besjon Cifliku commited on
Commit
9c3ade2
·
1 Parent(s): e29b232

feat: implement anomaly detection to filter suspicious word relations

Browse files
Dockerfile CHANGED
@@ -39,14 +39,25 @@ COPY --chown=appuser *.py ./
39
  # Copy pre-built frontend
40
  COPY --chown=appuser --from=frontend-build /app/frontend/dist ./frontend/dist
41
 
42
- # Data directories (HF cache, engine state, trained models)
43
- RUN mkdir -p /data/huggingface /data/engine_state /data/w2v_state /data/trained_model \
44
  && chown -R appuser:appuser /app /data
45
 
46
  ENV HF_HOME=/data/huggingface
47
  ENV TRANSFORMERS_CACHE=/data/huggingface
48
  ENV ENGINE_STATE_DIR=/data/engine_state
49
  ENV W2V_STATE_DIR=/data/w2v_state
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  # Switch to non-root user
52
  USER appuser
 
39
  # Copy pre-built frontend
40
  COPY --chown=appuser --from=frontend-build /app/frontend/dist ./frontend/dist
41
 
42
+ # Data directories (HF cache, gensim/GloVe cache, engine state, trained models)
43
+ RUN mkdir -p /data/huggingface /data/gensim-data /data/engine_state /data/w2v_state /data/trained_model \
44
  && chown -R appuser:appuser /app /data
45
 
46
  ENV HF_HOME=/data/huggingface
47
  ENV TRANSFORMERS_CACHE=/data/huggingface
48
  ENV ENGINE_STATE_DIR=/data/engine_state
49
  ENV W2V_STATE_DIR=/data/w2v_state
50
+ # GloVe background model cache for anomaly detection — keep it under /data so it
51
+ # sits on persistent storage and isn't re-downloaded (~128MB) on every cold start.
52
+ ENV GENSIM_DATA_DIR=/data/gensim-data
53
+ # Override the background model (e.g. glove-wiki-gigaword-50) without code changes.
54
+ ENV BACKGROUND_MODEL=glove-wiki-gigaword-100
55
+
56
+ # Bake the background model into the image so anomaly detection works offline,
57
+ # with no runtime download or cold-start wait (adds ~128MB to the image).
58
+ # return_path=True just ensures the download/cache without loading it into RAM.
59
+ RUN uv run python -c "import os, gensim.downloader as d; d.load(os.environ['BACKGROUND_MODEL'], return_path=True)" \
60
+ && chown -R appuser:appuser /data/gensim-data
61
 
62
  # Switch to non-root user
63
  USER appuser
anomaly.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anomalous-relation detection — finding "code word" candidates.
3
+
4
+ A code word is a *common English word used uncommonly in this corpus*. We make
5
+ that precise by contrasting two distributional word spaces:
6
+
7
+ - the corpus Word2Vec (how words associate IN these documents), and
8
+ - a pretrained general-English model (how they associate NORMALLY).
9
+
10
+ The whole design avoids two traps:
11
+ 1. Raw cosines from different embedding spaces are NOT comparable, so we never
12
+ subtract a corpus cosine from a background cosine. We compare neighbour
13
+ *sets* (scale-free) and, when we need per-neighbour scores, we standardise
14
+ within each space (z-scores relative to the same anchor word) before
15
+ combining — that subtraction *is* legitimate.
16
+ 2. "Low similarity" is the default for almost all word pairs and is not a
17
+ signal. The signal is *surprise*: strong here, weak normally.
18
+
19
+ Three stages:
20
+ A. sweep — rank which words behave most differently here vs. normally
21
+ (neighbour-set divergence, z-scored across the vocabulary).
22
+ B. relations — for one flagged word, the specific neighbours that are
23
+ strong in-corpus but weak/absent in general English.
24
+ C. incongruence — uses the transformer to find the specific occurrences
25
+ (chunks/docs) where a keyword is used unlike its norm.
26
+
27
+ Stages A/B need the corpus Word2Vec + background model. Stage C needs the
28
+ transformer engine (contextual embeddings), which is the only one of the three
29
+ models that can judge a single *occurrence* in context.
30
+ """
31
+
32
+ import logging
33
+ import re
34
+ import threading
35
+ from typing import Optional
36
+
37
+ import numpy as np
38
+
39
+ from contextual_similarity import ContextualSimilarityEngine
40
+ from word2vec_baseline import Word2VecEngine
41
+ from background_model import BackgroundModel
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Reuse the engine's stopword list so the three tools agree on what to ignore.
46
+ _STOPWORDS = ContextualSimilarityEngine._STOPWORDS
47
+ _ALPHA = re.compile(r"^[a-z]+$")
48
+
49
+ # Capitalised spans (names/orgs) and dates, for the Stage-C investigation view.
50
+ _ENTITY_RE = re.compile(r"\b[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\b")
51
+ _DATE_RE = re.compile(
52
+ r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}"
53
+ r"|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}"
54
+ r"|\d{4})\b"
55
+ )
56
+
57
+
58
+ def _is_candidate_word(word: str) -> bool:
59
+ """A gating word must be a plain lowercase English word, not a stopword/short token."""
60
+ return len(word) >= 3 and bool(_ALPHA.match(word)) and word not in _STOPWORDS
61
+
62
+
63
+ def _normalize_rows(mat: np.ndarray) -> np.ndarray:
64
+ norms = np.linalg.norm(mat, axis=1, keepdims=True)
65
+ norms[norms == 0] = 1e-12
66
+ return (mat / norms).astype(np.float32)
67
+
68
+
69
+ class SharedSpace:
70
+ """
71
+ The shared vocabulary of words present in BOTH the corpus Word2Vec and the
72
+ background model, with their normalised vectors in each space.
73
+
74
+ Restricting to shared words keeps neighbour-set overlap fair (both spaces
75
+ rank the *same* candidate set) and excludes names/jargon (absent from the
76
+ background) — which are domain vocabulary, not code words.
77
+ """
78
+
79
+ def __init__(self, words: list[str], corpus_vecs: np.ndarray,
80
+ bg_vecs: np.ndarray, corpus_counts: np.ndarray):
81
+ self.words = words
82
+ self.index = {w: i for i, w in enumerate(words)}
83
+ self.C = _normalize_rows(corpus_vecs) # (n, d_corpus)
84
+ self.B = _normalize_rows(bg_vecs) # (n, d_bg)
85
+ self.counts = corpus_counts # corpus frequency per word
86
+
87
+ def __len__(self) -> int:
88
+ return len(self.words)
89
+
90
+
91
+ def build_shared_space(
92
+ w2v: Word2VecEngine,
93
+ background: BackgroundModel,
94
+ min_count: int = 5,
95
+ max_vocab: int = 3000,
96
+ ) -> SharedSpace:
97
+ """
98
+ Build the shared-vocabulary space.
99
+
100
+ Args:
101
+ min_count: ignore corpus words rarer than this (their vectors are noise).
102
+ max_vocab: cap to the N most frequent shared words for tractability.
103
+ """
104
+ wv = w2v.model.wv
105
+ # Collect candidate words: frequent enough in the corpus, common in English.
106
+ candidates: list[tuple[str, int]] = []
107
+ for word in wv.index_to_key:
108
+ if not _is_candidate_word(word):
109
+ continue
110
+ count = wv.get_vecattr(word, "count")
111
+ if count < min_count:
112
+ continue
113
+ if not background.has(word):
114
+ continue
115
+ candidates.append((word, count))
116
+
117
+ # Keep the most frequent ones (their corpus vectors are the most reliable).
118
+ candidates.sort(key=lambda x: -x[1])
119
+ if len(candidates) > max_vocab:
120
+ logger.info("Shared vocab capped: %d candidates -> top %d by corpus frequency",
121
+ len(candidates), max_vocab)
122
+ candidates = candidates[:max_vocab]
123
+
124
+ words = [w for w, _ in candidates]
125
+ counts = np.array([c for _, c in candidates], dtype=np.int64)
126
+ corpus_vecs = np.array([wv[w] for w in words], dtype=np.float32)
127
+ bg_vecs = np.array([background.kv[w] for w in words], dtype=np.float32)
128
+ logger.info("Shared space built: %d words (corpus∩background, count>=%d)", len(words), min_count)
129
+ return SharedSpace(words, corpus_vecs, bg_vecs, counts)
130
+
131
+
132
+ # Module-level cache so the (expensive) shared space is reused across requests
133
+ # until the underlying corpus Word2Vec changes.
134
+ _cache_lock = threading.Lock()
135
+ _cached: dict = {"key": None, "space": None}
136
+
137
+
138
+ def get_shared_space(w2v: Word2VecEngine, background: BackgroundModel,
139
+ min_count: int, max_vocab: int) -> SharedSpace:
140
+ key = (id(w2v.model), len(w2v.model.wv), background.model_name, min_count, max_vocab)
141
+ with _cache_lock:
142
+ if _cached["key"] == key and _cached["space"] is not None:
143
+ return _cached["space"]
144
+ space = build_shared_space(w2v, background, min_count, max_vocab)
145
+ _cached["key"] = key
146
+ _cached["space"] = space
147
+ return space
148
+
149
+
150
+ def _topk_neighbours(sims: np.ndarray, self_idx: int, k: int) -> list[int]:
151
+ """Indices of the top-k most similar rows, excluding the anchor itself."""
152
+ sims = sims.copy()
153
+ sims[self_idx] = -np.inf
154
+ if k >= len(sims):
155
+ order = np.argsort(sims)[::-1]
156
+ else:
157
+ part = np.argpartition(sims, -k)[-k:]
158
+ order = part[np.argsort(sims[part])[::-1]]
159
+ return [int(i) for i in order]
160
+
161
+
162
+ # ------------------------------------------------------------------ #
163
+ # Stage A — corpus-wide sweep: which words behave most anomalously?
164
+ # ------------------------------------------------------------------ #
165
+
166
+ def sweep_anomalous_words(
167
+ w2v: Word2VecEngine,
168
+ background: BackgroundModel,
169
+ min_count: int = 5,
170
+ max_vocab: int = 3000,
171
+ neighbours: int = 25,
172
+ top_n: int = 30,
173
+ preview: int = 6,
174
+ ) -> dict:
175
+ """
176
+ Rank words by how differently they associate here vs. in general English.
177
+
178
+ For each shared word W we compute its top-`neighbours` in each space and
179
+ score `shift = 1 - overlap@k` (Jaccard-style overlap of the two neighbour
180
+ sets). Overlap is scale-free — no cross-space cosine arithmetic. We then
181
+ z-score `shift` across the whole vocabulary so flagging adapts to the corpus
182
+ instead of using a magic threshold.
183
+ """
184
+ space = get_shared_space(w2v, background, min_count, max_vocab)
185
+ n = len(space)
186
+ if n < neighbours + 2:
187
+ return {"ready": True, "vocab_size": n, "results": [],
188
+ "note": "Shared vocabulary too small to compute neighbourhoods."}
189
+
190
+ C, B = space.C, space.B
191
+ k = min(neighbours, n - 1)
192
+
193
+ shifts = np.zeros(n, dtype=np.float32)
194
+ corpus_nbrs: list[list[int]] = [None] * n
195
+ bg_nbrs: list[list[int]] = [None] * n
196
+ for i in range(n):
197
+ c_sims = C @ C[i]
198
+ b_sims = B @ B[i]
199
+ cn = _topk_neighbours(c_sims, i, k)
200
+ bn = _topk_neighbours(b_sims, i, k)
201
+ corpus_nbrs[i] = cn
202
+ bg_nbrs[i] = bn
203
+ overlap = len(set(cn) & set(bn)) / k
204
+ shifts[i] = 1.0 - overlap
205
+
206
+ mean, std = float(shifts.mean()), float(shifts.std())
207
+ std = std if std > 1e-9 else 1e-9
208
+
209
+ order = np.argsort(shifts)[::-1][:top_n]
210
+ results = []
211
+ for i in order:
212
+ i = int(i)
213
+ bg_set = set(bg_nbrs[i])
214
+ # Corpus neighbours that are NOT normal neighbours = the surprising ties.
215
+ surprising = [space.words[j] for j in corpus_nbrs[i] if j not in bg_set][:preview]
216
+ normal = [space.words[j] for j in bg_nbrs[i]][:preview]
217
+ results.append({
218
+ "word": space.words[i],
219
+ "corpus_frequency": int(space.counts[i]),
220
+ "shift": round(float(shifts[i]), 4),
221
+ "z_score": round((float(shifts[i]) - mean) / std, 3),
222
+ "surprising_neighbors": surprising, # strong here, absent normally
223
+ "normal_neighbors": normal, # what's normal in English
224
+ })
225
+
226
+ return {
227
+ "ready": True,
228
+ "vocab_size": n,
229
+ "neighbours": k,
230
+ "shift_mean": round(mean, 4),
231
+ "shift_std": round(std, 4),
232
+ "results": results,
233
+ }
234
+
235
+
236
+ # ------------------------------------------------------------------ #
237
+ # Stage B — per-relation surprise for a single flagged word.
238
+ # ------------------------------------------------------------------ #
239
+
240
+ def relation_surprise(
241
+ word: str,
242
+ w2v: Word2VecEngine,
243
+ background: BackgroundModel,
244
+ min_count: int = 5,
245
+ max_vocab: int = 3000,
246
+ top_k: int = 15,
247
+ ) -> dict:
248
+ """
249
+ For a single word, the neighbours that are strong in-corpus but weak/absent
250
+ in general English — the concrete "pizza -> fitness" rows.
251
+
252
+ We standardise each space's similarities to the anchor word into z-scores
253
+ (dimensionless, relative to the same anchor), then `surprise = corpus_z -
254
+ background_z`. Subtracting two within-space z-scores IS valid, unlike
255
+ subtracting raw cross-space cosines.
256
+ """
257
+ word = word.lower().strip()
258
+ space = get_shared_space(w2v, background, min_count, max_vocab)
259
+
260
+ if word not in space.index:
261
+ in_corpus = word in w2v.model.wv
262
+ in_bg = background.has(word)
263
+ if in_corpus and not in_bg:
264
+ reason = ("not a common English word (absent from the background model), "
265
+ "so it's treated as domain vocabulary — a name/jargon, not a code-word candidate")
266
+ elif not in_corpus:
267
+ reason = "not in the corpus vocabulary (or too rare)"
268
+ else:
269
+ reason = "below the minimum corpus frequency"
270
+ return {"word": word, "ready": True, "found": False, "reason": reason, "relations": []}
271
+
272
+ i = space.index[word]
273
+ c_sims = space.C @ space.C[i]
274
+ b_sims = space.B @ space.B[i]
275
+
276
+ def zscore(sims: np.ndarray) -> np.ndarray:
277
+ m, s = sims.mean(), sims.std()
278
+ return (sims - m) / (s if s > 1e-9 else 1e-9)
279
+
280
+ c_z, b_z = zscore(c_sims), zscore(b_sims)
281
+ surprise = c_z - b_z
282
+ surprise[i] = -np.inf # exclude self
283
+
284
+ order = np.argsort(surprise)[::-1][:top_k]
285
+ relations = [{
286
+ "neighbor": space.words[j],
287
+ "corpus_sim": round(float(c_sims[j]), 4), # raw cosines: display only
288
+ "background_sim": round(float(b_sims[j]), 4),
289
+ "corpus_z": round(float(c_z[j]), 3),
290
+ "background_z": round(float(b_z[j]), 3),
291
+ "surprise": round(float(surprise[j]), 3),
292
+ } for j in (int(x) for x in order)]
293
+
294
+ # Contrast: what this word's NORMAL neighbours are, in general English.
295
+ normal_order = _topk_neighbours(b_sims, i, min(top_k, len(space) - 1))
296
+ normal = [{"neighbor": space.words[j], "background_sim": round(float(b_sims[j]), 4)}
297
+ for j in normal_order]
298
+
299
+ return {
300
+ "word": word,
301
+ "ready": True,
302
+ "found": True,
303
+ "corpus_frequency": int(space.counts[i]),
304
+ "relations": relations,
305
+ "normal_neighbors": normal,
306
+ }
307
+
308
+
309
+ # ------------------------------------------------------------------ #
310
+ # Stage C — contextual incongruence (the "zoom in"), via the transformer.
311
+ # ------------------------------------------------------------------ #
312
+
313
+ def _extract_entities(text: str, limit: int = 8) -> list[str]:
314
+ """Capitalised name-like spans and dates, for the investigation view."""
315
+ found: list[str] = []
316
+ seen: set[str] = set()
317
+ for m in _ENTITY_RE.finditer(text):
318
+ token = m.group().strip()
319
+ # Skip single sentence-initial capitalised stopwords ("The", "And", ...)
320
+ if " " not in token and token.lower() in _STOPWORDS:
321
+ continue
322
+ if token.lower() not in seen:
323
+ seen.add(token.lower())
324
+ found.append(token)
325
+ for m in _DATE_RE.finditer(text):
326
+ d = m.group()
327
+ if d.lower() not in seen:
328
+ seen.add(d.lower())
329
+ found.append(d)
330
+ return found[:limit]
331
+
332
+
333
+ def contextual_incongruence(
334
+ engine: ContextualSimilarityEngine,
335
+ keyword: str,
336
+ canonical_meaning: Optional[str] = None,
337
+ top_k: int = 10,
338
+ ) -> dict:
339
+ """
340
+ Find the occurrences where `keyword` is used most unlike its norm.
341
+
342
+ Reference meaning is either:
343
+ - `canonical_meaning` (a gloss you supply, e.g. "pizza, an Italian food"), or
344
+ - the centroid of all the keyword's occurrence embeddings (its typical
345
+ usage in THIS corpus) when no gloss is given.
346
+
347
+ Per occurrence: incongruence = 1 - cos(chunk_embedding, reference). The
348
+ highest-incongruence chunks are the candidate coded usages — returned with
349
+ doc/snippet and co-occurring entities so you can read them directly.
350
+ """
351
+ engine._ensure_index()
352
+ contexts = engine.find_keyword_contexts(keyword)
353
+ if not contexts:
354
+ return {"keyword": keyword, "total_occurrences": 0, "occurrences": []}
355
+
356
+ chunk_indices = [engine.chunks.index(ctx.chunk) for ctx in contexts]
357
+ embeds = engine.embeddings[chunk_indices] # rows are L2-normalised (build_index)
358
+
359
+ if canonical_meaning and canonical_meaning.strip():
360
+ ref = engine.model.encode([canonical_meaning], normalize_embeddings=True,
361
+ convert_to_numpy=True)[0].astype(np.float32)
362
+ ref_label = canonical_meaning.strip()
363
+ ref_kind = "gloss"
364
+ else:
365
+ centroid = embeds.mean(axis=0)
366
+ norm = np.linalg.norm(centroid)
367
+ ref = (centroid / (norm if norm > 0 else 1e-12)).astype(np.float32)
368
+ ref_label = "corpus-typical usage (centroid of all occurrences)"
369
+ ref_kind = "centroid"
370
+
371
+ sims = embeds @ ref # cosine (both sides unit-norm)
372
+ incong = 1.0 - sims
373
+ median_incong = float(np.median(incong))
374
+
375
+ order = np.argsort(incong)[::-1][:top_k]
376
+ occurrences = []
377
+ for j in (int(x) for x in order):
378
+ ctx = contexts[j]
379
+ text = ctx.chunk.text
380
+ if ctx.highlight_positions:
381
+ start, end = ctx.highlight_positions[0]
382
+ s, e = max(0, start - 120), min(len(text), end + 120)
383
+ snippet = ("..." if s > 0 else "") + text[s:e].strip() + ("..." if e < len(text) else "")
384
+ else:
385
+ snippet = text[:240]
386
+ occurrences.append({
387
+ "doc_id": ctx.chunk.doc_id,
388
+ "chunk_index": ctx.chunk.chunk_index,
389
+ "incongruence": round(float(incong[j]), 4),
390
+ "snippet": snippet,
391
+ "entities": _extract_entities(text),
392
+ })
393
+
394
+ return {
395
+ "keyword": keyword,
396
+ "total_occurrences": len(contexts),
397
+ "reference": ref_label,
398
+ "reference_kind": ref_kind,
399
+ "median_incongruence": round(median_incong, 4),
400
+ "occurrences": occurrences,
401
+ }
background_model.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Background language model — pretrained general-English word vectors.
3
+
4
+ This is the "what's normal in English" reference used by anomaly detection.
5
+ It is deliberately a *distributional* word-embedding model (GloVe), the same
6
+ *kind* of object as the corpus Word2Vec: comparing a word's neighbourhood in
7
+ the corpus against its neighbourhood here is only meaningful when both spaces
8
+ are word-co-occurrence embeddings. A sentence-transformer (or an OpenAI text
9
+ embedding) is the wrong type for this role — it models text similarity, not
10
+ word co-occurrence, and lives in an unrelated vector space.
11
+
12
+ The model is loaded lazily on first use (gensim downloads & caches it to
13
+ ~/gensim-data, or $GENSIM_DATA_DIR). A failed download degrades gracefully:
14
+ callers see `ready == False` instead of a crash.
15
+ """
16
+
17
+ import logging
18
+ import os
19
+ import threading
20
+ from typing import Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # glove-wiki-gigaword-100: ~128MB, 400k lowercase words, dim=100.
25
+ # Override with BACKGROUND_MODEL env var (e.g. glove-wiki-gigaword-50).
26
+ DEFAULT_MODEL = os.environ.get("BACKGROUND_MODEL", "glove-wiki-gigaword-100")
27
+
28
+
29
+ class BackgroundModel:
30
+ """Lazily-loaded pretrained general-English word vectors (gensim KeyedVectors)."""
31
+
32
+ def __init__(self, model_name: str = DEFAULT_MODEL):
33
+ self.model_name = model_name
34
+ self._kv = None # gensim KeyedVectors once loaded
35
+ self._lock = threading.Lock()
36
+ self._load_failed = False
37
+
38
+ @property
39
+ def ready(self) -> bool:
40
+ return self._kv is not None
41
+
42
+ @property
43
+ def kv(self):
44
+ """The underlying gensim KeyedVectors, or None if not loaded."""
45
+ return self._kv
46
+
47
+ def load(self) -> bool:
48
+ """
49
+ Load the model if needed. Returns True on success, False on failure.
50
+
51
+ Thread-safe and idempotent. The first call may download ~128MB; later
52
+ calls (and restarts, if the cache survives) are instant. On failure the
53
+ model is marked failed so we don't retry a doomed download every request.
54
+ """
55
+ if self._kv is not None:
56
+ return True
57
+ with self._lock:
58
+ if self._kv is not None:
59
+ return True
60
+ if self._load_failed:
61
+ return False
62
+ try:
63
+ import gensim.downloader as gd
64
+ logger.info("Loading background model '%s' (first use may download ~100MB)...",
65
+ self.model_name)
66
+ self._kv = gd.load(self.model_name)
67
+ logger.info("Background model ready: %d words, dim=%d",
68
+ len(self._kv), self._kv.vector_size)
69
+ return True
70
+ except Exception:
71
+ logger.exception("Failed to load background model '%s' — "
72
+ "anomaly detection will be unavailable.", self.model_name)
73
+ self._load_failed = True
74
+ return False
75
+
76
+ def has(self, word: str) -> bool:
77
+ """True if the word exists in the background vocabulary (i.e. common English)."""
78
+ return self._kv is not None and word in self._kv
79
+
80
+ def similarity(self, a: str, b: str) -> Optional[float]:
81
+ """Cosine similarity between two words in general English, or None if either is OOV."""
82
+ if self._kv is None or a not in self._kv or b not in self._kv:
83
+ return None
84
+ return float(self._kv.similarity(a, b))
85
+
86
+ def status(self) -> dict:
87
+ return {
88
+ "model_name": self.model_name,
89
+ "ready": self.ready,
90
+ "load_failed": self._load_failed,
91
+ "vocab_size": len(self._kv) if self._kv is not None else 0,
92
+ "vector_size": int(self._kv.vector_size) if self._kv is not None else 0,
93
+ }
docker-compose.yml CHANGED
@@ -6,8 +6,11 @@ services:
6
  volumes:
7
  # Persist HuggingFace model cache between restarts
8
  - hf-cache:/data/huggingface
9
- # Persist engine state and trained models
 
 
10
  - engine-state:/data/engine_state
 
11
  - ./trained_model:/data/trained_model
12
  environment:
13
  - HOST=0.0.0.0
@@ -15,4 +18,6 @@ services:
15
 
16
  volumes:
17
  hf-cache:
 
18
  engine-state:
 
 
6
  volumes:
7
  # Persist HuggingFace model cache between restarts
8
  - hf-cache:/data/huggingface
9
+ # Persist the GloVe background model (anomaly detection) so it's not re-downloaded
10
+ - gensim-cache:/data/gensim-data
11
+ # Persist engine state, Word2Vec state, and trained models
12
  - engine-state:/data/engine_state
13
+ - w2v-state:/data/w2v_state
14
  - ./trained_model:/data/trained_model
15
  environment:
16
  - HOST=0.0.0.0
 
18
 
19
  volumes:
20
  hf-cache:
21
+ gensim-cache:
22
  engine-state:
23
+ w2v-state:
frontend/src/App.tsx CHANGED
@@ -5,25 +5,21 @@ import TrainingPanel from "./components/TrainingPanel";
5
  import EngineSetup from "./components/EngineSetup";
6
  import SemanticSearch from "./components/SemanticSearch";
7
  import TextCompare from "./components/TextCompare";
8
- import KeywordAnalysis from "./components/KeywordAnalysis";
9
- import KeywordMatcher from "./components/KeywordMatcher";
10
- import BatchAnalysis from "./components/BatchAnalysis";
11
  import SimilarWords from "./components/SimilarWords";
12
  import ContextAnalysis from "./components/ContextAnalysis";
 
13
  import Word2VecPanel from "./components/Word2VecPanel";
14
- import Word2VecTools from "./components/Word2VecTools";
15
  import DatasetPanel from "./components/DatasetPanel";
16
  import MetricCard from "./components/MetricCard";
17
  import "./styles.css";
18
 
19
- type NavGroup = "data" | "training" | "analysis";
20
  type TrainingTab = "model" | "w2v";
21
- type AnalysisTab = "context" | "words" | "search" | "compare" | "keyword" | "match" | "batch";
22
 
23
- const STEPS: { id: NavGroup; label: string; needsIndex?: boolean }[] = [
24
  { id: "data", label: "Data & Setup" },
25
  { id: "training", label: "Training" },
26
- { id: "analysis", label: "Analysis", needsIndex: true },
27
  ];
28
 
29
  const TRAINING_TABS: { id: TrainingTab; label: string }[] = [
@@ -33,25 +29,18 @@ const TRAINING_TABS: { id: TrainingTab; label: string }[] = [
33
 
34
  const ANALYSIS_TABS: { id: AnalysisTab; label: string }[] = [
35
  { id: "context", label: "Context" },
36
- { id: "words", label: "Similar Words" },
37
- { id: "search", label: "Search" },
38
- { id: "compare", label: "Compare" },
39
- { id: "keyword", label: "Keywords" },
40
- { id: "match", label: "Matcher" },
41
- { id: "batch", label: "Batch" },
42
  ];
43
 
44
  export default function App() {
45
  const [group, setGroup] = useState<NavGroup>("data");
46
- const [trainingTab, setTrainingTab] = useState<TrainingTab>("model");
47
  const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("context");
48
  const [stats, setStats] = useState<CorpusStats | null>(null);
49
  const [showManualSetup, setShowManualSetup] = useState(false);
50
  const [serverError, setServerError] = useState<string | null>(null);
51
  const [w2vReady, setW2vReady] = useState(false);
52
  const [w2vInfo, setW2vInfo] = useState<{ vocab_size: number; sentences: number; vector_size: number } | null>(null);
53
- const [resetLoading, setResetLoading] = useState(false);
54
- const ready = stats !== null && stats.index_built;
55
 
56
  useEffect(() => {
57
  checkConnection().then((err) => {
@@ -62,6 +51,7 @@ export default function App() {
62
  if (res.ready) {
63
  setW2vReady(true);
64
  setW2vInfo({ vocab_size: res.vocab_size!, sentences: res.sentences!, vector_size: res.vector_size! });
 
65
  }
66
  }).catch(() => {});
67
  }
@@ -77,94 +67,6 @@ export default function App() {
77
  setW2vInfo(ready && info ? info : null);
78
  }
79
 
80
- async function handleReset() {
81
- setResetLoading(true);
82
- try {
83
- await api.w2vReset();
84
- setW2vReady(false);
85
- setW2vInfo(null);
86
- } catch {
87
- // ignore
88
- } finally {
89
- setResetLoading(false);
90
- }
91
- }
92
-
93
- function handleStepClick(id: NavGroup, needsIndex?: boolean) {
94
- if (needsIndex && !ready) return;
95
- setGroup(id);
96
- }
97
-
98
- // ── W2V trained: stats bar + analysis tabs, no stepper ──
99
- if (w2vReady && w2vInfo) {
100
- return (
101
- <div className="app">
102
- <header className="app-header">
103
- <h1>Contextual Similarity Engine</h1>
104
- {stats && (
105
- <div className="header-stats">
106
- <span className="badge">{stats.model_name}</span>
107
- <span className="badge">{stats.total_documents} docs</span>
108
- <span className="badge">{stats.total_chunks} chunks</span>
109
- </div>
110
- )}
111
- </header>
112
-
113
- {serverError && (
114
- <div className="server-error-banner">
115
- <strong>Server unavailable:</strong> {serverError}
116
- </div>
117
- )}
118
-
119
- {/* W2V stats bar */}
120
- <div className="content">
121
- <div className="panel">
122
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
123
- <h2 style={{ margin: 0 }}>Word2Vec Baseline</h2>
124
- <button className="btn btn-secondary" onClick={handleReset} disabled={resetLoading}
125
- style={{ fontSize: "0.85em" }}>
126
- {resetLoading ? "Resetting..." : "Reset & Retrain"}
127
- </button>
128
- </div>
129
- <div className="metric-grid" style={{ marginTop: 12 }}>
130
- <MetricCard value={w2vInfo.vocab_size} label="Vocabulary" />
131
- <MetricCard value={w2vInfo.sentences} label="Sentences" />
132
- <MetricCard value={w2vInfo.vector_size} label="Dimensions" />
133
- </div>
134
- </div>
135
-
136
- {/* W2V-specific tools: Similar Words, Compare, Semantic Search */}
137
- <Word2VecTools />
138
- </div>
139
-
140
- {/* Transformer Analysis sub-tabs */}
141
- <nav className="subtabs">
142
- {ANALYSIS_TABS.map((t) => (
143
- <button
144
- key={t.id}
145
- className={`subtab ${analysisTab === t.id ? "subtab-active" : ""}`}
146
- onClick={() => setAnalysisTab(t.id)}
147
- >
148
- {t.label}
149
- </button>
150
- ))}
151
- </nav>
152
-
153
- {/* Analysis content */}
154
- <main className="content">
155
- {analysisTab === "context" && <ContextAnalysis />}
156
- {analysisTab === "words" && <SimilarWords />}
157
- {analysisTab === "search" && <SemanticSearch />}
158
- {analysisTab === "compare" && <TextCompare />}
159
- {analysisTab === "keyword" && <KeywordAnalysis />}
160
- {analysisTab === "match" && <KeywordMatcher />}
161
- {analysisTab === "batch" && <BatchAnalysis />}
162
- </main>
163
- </div>
164
- );
165
- }
166
-
167
- // ── Normal stepper flow ──
168
  return (
169
  <div className="app">
170
  <header className="app-header">
@@ -174,9 +76,6 @@ export default function App() {
174
  <span className="badge">{stats.model_name}</span>
175
  <span className="badge">{stats.total_documents} docs</span>
176
  <span className="badge">{stats.total_chunks} chunks</span>
177
- <span className={`badge ${stats.index_built ? "badge-ok" : "badge-warn"}`}>
178
- {stats.index_built ? "Index ready" : "Index not built"}
179
- </span>
180
  </div>
181
  )}
182
  </header>
@@ -187,24 +86,22 @@ export default function App() {
187
  </div>
188
  )}
189
 
190
- {/* Progress Stepper */}
 
191
  <nav className="stepper">
192
  {STEPS.map((step, i) => {
193
- const disabled = step.needsIndex && !ready;
194
  const active = group === step.id;
195
- const done = step.id === "data" && ready;
 
196
  return (
197
  <Fragment key={step.id}>
198
- {i > 0 && (
199
- <div className={`stepper-line ${!disabled ? "stepper-line-active" : ""}`} />
200
- )}
201
  <div className="stepper-item">
202
  <button
203
  className={`stepper-circle ${active ? "stepper-active" : ""} ${done && !active ? "stepper-done" : ""}`}
204
- onClick={() => handleStepClick(step.id, step.needsIndex)}
205
- disabled={disabled}
206
  >
207
- {done && !active ? "\u2713" : i + 1}
208
  </button>
209
  <span className={`stepper-label ${active ? "stepper-label-active" : ""}`}>
210
  {step.label}
@@ -214,62 +111,79 @@ export default function App() {
214
  );
215
  })}
216
  </nav>
217
-
218
- {/* Sub-tabs */}
219
- {group === "training" && (
220
- <nav className="subtabs">
221
- {TRAINING_TABS.map((t) => (
222
- <button
223
- key={t.id}
224
- className={`subtab ${trainingTab === t.id ? "subtab-active" : ""}`}
225
- onClick={() => setTrainingTab(t.id)}
226
- >
227
- {t.label}
228
- </button>
229
- ))}
230
- </nav>
231
- )}
232
-
233
- {group === "analysis" && (
234
- <nav className="subtabs">
235
- {ANALYSIS_TABS.map((t) => (
236
- <button
237
- key={t.id}
238
- className={`subtab ${analysisTab === t.id ? "subtab-active" : ""}`}
239
- onClick={() => setAnalysisTab(t.id)}
240
- >
241
- {t.label}
242
- </button>
243
- ))}
244
- </nav>
245
  )}
246
 
247
  {/* Content */}
248
  <main className="content">
249
  {group === "data" && (
250
  <>
251
- <DatasetPanel onStatsUpdate={setStats} />
252
  <button
253
  className="collapsible-toggle"
254
  onClick={() => setShowManualSetup(!showManualSetup)}
255
  >
256
- <span className="collapsible-arrow">{showManualSetup ? "\u25be" : "\u25b8"}</span>
257
  Or add documents manually
258
  </button>
259
  {showManualSetup && <EngineSetup onStatsUpdate={setStats} />}
260
  </>
261
  )}
262
 
263
- {group === "training" && trainingTab === "model" && <TrainingPanel />}
264
- {group === "training" && trainingTab === "w2v" && <Word2VecPanel onReady={handleW2vReady} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
- {group === "analysis" && analysisTab === "context" && <ContextAnalysis />}
267
- {group === "analysis" && analysisTab === "words" && <SimilarWords />}
268
- {group === "analysis" && analysisTab === "search" && <SemanticSearch />}
269
- {group === "analysis" && analysisTab === "compare" && <TextCompare />}
270
- {group === "analysis" && analysisTab === "keyword" && <KeywordAnalysis />}
271
- {group === "analysis" && analysisTab === "match" && <KeywordMatcher />}
272
- {group === "analysis" && analysisTab === "batch" && <BatchAnalysis />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  </main>
274
  </div>
275
  );
 
5
  import EngineSetup from "./components/EngineSetup";
6
  import SemanticSearch from "./components/SemanticSearch";
7
  import TextCompare from "./components/TextCompare";
 
 
 
8
  import SimilarWords from "./components/SimilarWords";
9
  import ContextAnalysis from "./components/ContextAnalysis";
10
+ import AnomalyPanel from "./components/AnomalyPanel";
11
  import Word2VecPanel from "./components/Word2VecPanel";
 
12
  import DatasetPanel from "./components/DatasetPanel";
13
  import MetricCard from "./components/MetricCard";
14
  import "./styles.css";
15
 
16
+ type NavGroup = "data" | "training";
17
  type TrainingTab = "model" | "w2v";
18
+ type AnalysisTab = "context" | "anomalies";
19
 
20
+ const STEPS: { id: NavGroup; label: string }[] = [
21
  { id: "data", label: "Data & Setup" },
22
  { id: "training", label: "Training" },
 
23
  ];
24
 
25
  const TRAINING_TABS: { id: TrainingTab; label: string }[] = [
 
29
 
30
  const ANALYSIS_TABS: { id: AnalysisTab; label: string }[] = [
31
  { id: "context", label: "Context" },
32
+ { id: "anomalies", label: "Anomalies" },
 
 
 
 
 
33
  ];
34
 
35
  export default function App() {
36
  const [group, setGroup] = useState<NavGroup>("data");
37
+ const [trainingTab, setTrainingTab] = useState<TrainingTab>("w2v");
38
  const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("context");
39
  const [stats, setStats] = useState<CorpusStats | null>(null);
40
  const [showManualSetup, setShowManualSetup] = useState(false);
41
  const [serverError, setServerError] = useState<string | null>(null);
42
  const [w2vReady, setW2vReady] = useState(false);
43
  const [w2vInfo, setW2vInfo] = useState<{ vocab_size: number; sentences: number; vector_size: number } | null>(null);
 
 
44
 
45
  useEffect(() => {
46
  checkConnection().then((err) => {
 
51
  if (res.ready) {
52
  setW2vReady(true);
53
  setW2vInfo({ vocab_size: res.vocab_size!, sentences: res.sentences!, vector_size: res.vector_size! });
54
+ setGroup("training");
55
  }
56
  }).catch(() => {});
57
  }
 
67
  setW2vInfo(ready && info ? info : null);
68
  }
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  return (
71
  <div className="app">
72
  <header className="app-header">
 
76
  <span className="badge">{stats.model_name}</span>
77
  <span className="badge">{stats.total_documents} docs</span>
78
  <span className="badge">{stats.total_chunks} chunks</span>
 
 
 
79
  </div>
80
  )}
81
  </header>
 
86
  </div>
87
  )}
88
 
89
+ {/* Progress Stepper — hidden once training is complete */}
90
+ {!w2vReady && (
91
  <nav className="stepper">
92
  {STEPS.map((step, i) => {
 
93
  const active = group === step.id;
94
+ const done = (step.id === "data" && stats !== null && stats.index_built)
95
+ || (step.id === "training" && w2vReady);
96
  return (
97
  <Fragment key={step.id}>
98
+ {i > 0 && <div className="stepper-line stepper-line-active" />}
 
 
99
  <div className="stepper-item">
100
  <button
101
  className={`stepper-circle ${active ? "stepper-active" : ""} ${done && !active ? "stepper-done" : ""}`}
102
+ onClick={() => setGroup(step.id)}
 
103
  >
104
+ {done && !active ? "" : i + 1}
105
  </button>
106
  <span className={`stepper-label ${active ? "stepper-label-active" : ""}`}>
107
  {step.label}
 
111
  );
112
  })}
113
  </nav>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  )}
115
 
116
  {/* Content */}
117
  <main className="content">
118
  {group === "data" && (
119
  <>
120
+ <DatasetPanel onStatsUpdate={setStats} onLoaded={() => setGroup("training")} />
121
  <button
122
  className="collapsible-toggle"
123
  onClick={() => setShowManualSetup(!showManualSetup)}
124
  >
125
+ <span className="collapsible-arrow">{showManualSetup ? "" : ""}</span>
126
  Or add documents manually
127
  </button>
128
  {showManualSetup && <EngineSetup onStatsUpdate={setStats} />}
129
  </>
130
  )}
131
 
132
+ {group === "training" && !w2vReady && (
133
+ <>
134
+ <nav className="subtabs">
135
+ {TRAINING_TABS.map((t) => (
136
+ <button
137
+ key={t.id}
138
+ className={`subtab ${trainingTab === t.id ? "subtab-active" : ""}`}
139
+ onClick={() => setTrainingTab(t.id)}
140
+ >
141
+ {t.label}
142
+ </button>
143
+ ))}
144
+ </nav>
145
+ {trainingTab === "model" && <TrainingPanel />}
146
+ {trainingTab === "w2v" && <Word2VecPanel onReady={handleW2vReady} />}
147
+ </>
148
+ )}
149
+
150
+ {group === "training" && w2vReady && w2vInfo && (
151
+ <>
152
+ <div className="panel">
153
+ <h2 style={{ marginTop: 0 }}>Trained Corpus</h2>
154
+ <p className="panel-desc">
155
+ Word2Vec model is trained and persisted. Use the tools below to explore similarity.
156
+ </p>
157
+ <div className="metric-grid">
158
+ <MetricCard value={w2vInfo.vocab_size} label="Vocabulary" />
159
+ <MetricCard value={w2vInfo.sentences} label="Sentences" />
160
+ <MetricCard value={w2vInfo.vector_size} label="Dimensions" />
161
+ </div>
162
+ </div>
163
 
164
+ <nav className="subtabs">
165
+ {ANALYSIS_TABS.map((t) => (
166
+ <button
167
+ key={t.id}
168
+ className={`subtab ${analysisTab === t.id ? "subtab-active" : ""}`}
169
+ onClick={() => setAnalysisTab(t.id)}
170
+ >
171
+ {t.label}
172
+ </button>
173
+ ))}
174
+ </nav>
175
+
176
+ {analysisTab === "context" && (
177
+ <>
178
+ <SimilarWords />
179
+ <TextCompare />
180
+ <SemanticSearch />
181
+ <ContextAnalysis />
182
+ </>
183
+ )}
184
+ {analysisTab === "anomalies" && <AnomalyPanel />}
185
+ </>
186
+ )}
187
  </main>
188
  </div>
189
  );
frontend/src/api.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
  W2VInitResponse, W2VQueryResult, W2VSimilarWord,
10
  DatasetInfo, DatasetLoadRequest, DatasetLoadResponse, DatasetPreviewResponse,
11
  ContextAnalysisResponse,
 
12
  } from "./types";
13
 
14
  // HuggingFace Spaces proxy requires the __sign token on every request.
@@ -146,9 +147,6 @@ export const api = {
146
  w2vStatus: () =>
147
  client.get<{ ready: boolean; vocab_size?: number; sentences?: number; vector_size?: number; has_saved_state?: boolean }>("/w2v/status").then(r => r.data),
148
 
149
- w2vReset: () =>
150
- client.post<{ status: string; message: string }>("/w2v/reset").then(r => r.data),
151
-
152
  w2vCompare: (data: { text_a: string; text_b: string }) =>
153
  client.post<CompareResponse>("/w2v/compare", data).then(r => r.data),
154
 
@@ -158,6 +156,22 @@ export const api = {
158
  w2vSimilarWords: (data: { word: string; top_k: number }) =>
159
  client.post<{ word: string; similar: W2VSimilarWord[] }>("/w2v/similar-words", data).then(r => r.data),
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  // ---- Dataset (HuggingFace) ----
162
  datasetInfo: () =>
163
  client.get<DatasetInfo>("/dataset/info").then(r => r.data),
 
9
  W2VInitResponse, W2VQueryResult, W2VSimilarWord,
10
  DatasetInfo, DatasetLoadRequest, DatasetLoadResponse, DatasetPreviewResponse,
11
  ContextAnalysisResponse,
12
+ BackgroundStatus, AnomalySweepResponse, AnomalyRelationResponse, IncongruenceResponse,
13
  } from "./types";
14
 
15
  // HuggingFace Spaces proxy requires the __sign token on every request.
 
147
  w2vStatus: () =>
148
  client.get<{ ready: boolean; vocab_size?: number; sentences?: number; vector_size?: number; has_saved_state?: boolean }>("/w2v/status").then(r => r.data),
149
 
 
 
 
150
  w2vCompare: (data: { text_a: string; text_b: string }) =>
151
  client.post<CompareResponse>("/w2v/compare", data).then(r => r.data),
152
 
 
156
  w2vSimilarWords: (data: { word: string; top_k: number }) =>
157
  client.post<{ word: string; similar: W2VSimilarWord[] }>("/w2v/similar-words", data).then(r => r.data),
158
 
159
+ // ---- Anomalous-relation detection ----
160
+ backgroundStatus: () =>
161
+ client.get<BackgroundStatus>("/background/status").then(r => r.data),
162
+
163
+ backgroundLoad: () =>
164
+ client.post<BackgroundStatus>("/background/load", null, long).then(r => r.data),
165
+
166
+ analyzeAnomalies: (data?: { min_count?: number; max_vocab?: number; neighbours?: number; top_n?: number }) =>
167
+ client.post<AnomalySweepResponse>("/analyze/anomalies", data ?? {}, long).then(r => r.data),
168
+
169
+ analyzeAnomalyRelations: (data: { word: string; top_k?: number }) =>
170
+ client.post<AnomalyRelationResponse>("/analyze/anomaly-relations", data).then(r => r.data),
171
+
172
+ analyzeIncongruence: (data: { keyword: string; canonical_meaning?: string; top_k?: number }) =>
173
+ client.post<IncongruenceResponse>("/analyze/incongruence", data).then(r => r.data),
174
+
175
  // ---- Dataset (HuggingFace) ----
176
  datasetInfo: () =>
177
  client.get<DatasetInfo>("/dataset/info").then(r => r.data),
frontend/src/components/AnomalyPanel.tsx ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { api } from "../api";
3
+ import type {
4
+ BackgroundStatus, AnomalySweepResponse, AnomalyRelationResponse, IncongruenceResponse,
5
+ } from "../types";
6
+ import { useApiCall } from "../hooks/useApiCall";
7
+ import ScoreBar from "./ScoreBar";
8
+ import StatusMessage from "./StatusMessage";
9
+
10
+ export default function AnomalyPanel() {
11
+ const [bg, setBg] = useState<BackgroundStatus | null>(null);
12
+ const [bgLoading, setBgLoading] = useState(false);
13
+ const [bgError, setBgError] = useState("");
14
+
15
+ // Stage A — corpus sweep
16
+ const [showAdvanced, setShowAdvanced] = useState(false);
17
+ const [minCount, setMinCount] = useState(5);
18
+ const [neighbours, setNeighbours] = useState(25);
19
+ const [topN, setTopN] = useState(30);
20
+ const sweep = useApiCall<AnomalySweepResponse>();
21
+
22
+ // Stage B — per-word relations
23
+ const [selectedWord, setSelectedWord] = useState<string | null>(null);
24
+ const relations = useApiCall<AnomalyRelationResponse>();
25
+
26
+ // Stage C — contextual incongruence (zoom in)
27
+ const [keyword, setKeyword] = useState("");
28
+ const [canonical, setCanonical] = useState("");
29
+ const incong = useApiCall<IncongruenceResponse>();
30
+
31
+ useEffect(() => {
32
+ api.backgroundStatus().then(setBg).catch(() => {});
33
+ }, []);
34
+
35
+ async function loadBackground() {
36
+ setBgLoading(true); setBgError("");
37
+ try {
38
+ setBg(await api.backgroundLoad());
39
+ } catch {
40
+ setBgError("Background model failed to load (network/disk). Anomaly detection needs it.");
41
+ } finally {
42
+ setBgLoading(false);
43
+ }
44
+ }
45
+
46
+ async function runSweep() {
47
+ setSelectedWord(null);
48
+ relations.clear();
49
+ const res = await sweep.run(() =>
50
+ api.analyzeAnomalies({ min_count: minCount, neighbours, top_n: topN }));
51
+ if (res && !bg?.ready) api.backgroundStatus().then(setBg).catch(() => {});
52
+ }
53
+
54
+ async function drillInto(word: string) {
55
+ setSelectedWord(word);
56
+ await relations.run(() => api.analyzeAnomalyRelations({ word, top_k: 15 }));
57
+ }
58
+
59
+ async function zoomIn(word: string, gloss?: string) {
60
+ setKeyword(word);
61
+ if (gloss !== undefined) setCanonical(gloss);
62
+ await incong.run(() =>
63
+ api.analyzeIncongruence({ keyword: word, canonical_meaning: gloss || undefined, top_k: 10 }));
64
+ document.getElementById("zoom-section")?.scrollIntoView({ behavior: "smooth" });
65
+ }
66
+
67
+ const bgReady = bg?.ready ?? false;
68
+
69
+ return (
70
+ <div>
71
+ {/* Background model status */}
72
+ <div className="panel">
73
+ <h2>Anomalous Relations</h2>
74
+ <p className="panel-desc">
75
+ Find <strong>code-word candidates</strong>: common English words that behave uncommonly
76
+ in this corpus. We contrast each word's neighbours in the corpus Word2Vec against a
77
+ pretrained general-English model (GloVe). A relation is flagged when it is{" "}
78
+ <em>strong here but weak/absent in normal English</em> — not merely "low similarity".
79
+ </p>
80
+ {bg && (
81
+ <div className="flex-row" style={{ alignItems: "center", gap: 8 }}>
82
+ <span
83
+ className="badge"
84
+ style={{
85
+ background: `rgba(${bgReady ? "74, 222, 128" : "255, 170, 0"}, 0.15)`,
86
+ color: bgReady ? "var(--ok)" : "var(--accent)",
87
+ }}
88
+ >
89
+ {bg.model_name}: {bgReady ? `ready (${bg.vocab_size.toLocaleString()} words)` : "not loaded"}
90
+ </span>
91
+ {!bgReady && (
92
+ <button className="btn" onClick={loadBackground} disabled={bgLoading}>
93
+ {bgLoading ? <><span className="spinner" /> Downloading…</> : "Load background model"}
94
+ </button>
95
+ )}
96
+ </div>
97
+ )}
98
+ {bgError && <div className="mt-2"><StatusMessage type="err" message={bgError} /></div>}
99
+ </div>
100
+
101
+ {/* Stage A — corpus sweep */}
102
+ <div className="panel">
103
+ <h3 style={{ marginTop: 0 }}>1 · Scan corpus for anomalous words</h3>
104
+ <p className="panel-desc">
105
+ Ranks words by neighbour-set divergence (z-scored across the vocabulary). Higher z = the
106
+ word's corpus associations look more unlike general English.
107
+ </p>
108
+
109
+ <button className="advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced)}>
110
+ {showAdvanced ? "▾" : "▸"} Advanced Settings
111
+ </button>
112
+ {showAdvanced && (
113
+ <div className="advanced-section">
114
+ <div className="form-row">
115
+ <div className="form-group" style={{ maxWidth: 130 }}>
116
+ <label>Min corpus freq</label>
117
+ <input type="number" value={minCount} onChange={e => setMinCount(+e.target.value)} min={1} max={1000} />
118
+ </div>
119
+ <div className="form-group" style={{ maxWidth: 130 }}>
120
+ <label>Neighbours (k)</label>
121
+ <input type="number" value={neighbours} onChange={e => setNeighbours(+e.target.value)} min={5} max={100} />
122
+ </div>
123
+ <div className="form-group" style={{ maxWidth: 130 }}>
124
+ <label>Top N results</label>
125
+ <input type="number" value={topN} onChange={e => setTopN(+e.target.value)} min={1} max={200} />
126
+ </div>
127
+ </div>
128
+ </div>
129
+ )}
130
+
131
+ <button className="btn btn-primary" onClick={runSweep} disabled={sweep.loading} style={{ marginTop: 8 }}>
132
+ {sweep.loading ? <><span className="spinner" /> Scanning…</> : "Scan corpus"}
133
+ </button>
134
+
135
+ {sweep.error && <div className="mt-2"><StatusMessage type="err" message={sweep.error} /></div>}
136
+ {sweep.data?.note && <div className="mt-2"><StatusMessage type="err" message={sweep.data.note} /></div>}
137
+
138
+ {sweep.data && sweep.data.results.length > 0 && (
139
+ <div className="mt-2">
140
+ <div className="section-label">
141
+ {sweep.data.results.length} flagged · shared vocab {sweep.data.vocab_size.toLocaleString()} ·
142
+ mean shift {sweep.data.shift_mean}
143
+ </div>
144
+ <table className="data-table">
145
+ <thead>
146
+ <tr>
147
+ <th>Word</th><th>Freq</th><th>z</th>
148
+ <th>Surprising neighbours (here, not normal)</th><th></th>
149
+ </tr>
150
+ </thead>
151
+ <tbody>
152
+ {sweep.data.results.map((r) => (
153
+ <tr
154
+ key={r.word}
155
+ onClick={() => drillInto(r.word)}
156
+ style={{ cursor: "pointer", background: selectedWord === r.word ? "rgba(108,140,255,0.08)" : undefined }}
157
+ >
158
+ <td style={{ fontWeight: 600 }}>{r.word}</td>
159
+ <td>{r.corpus_frequency}</td>
160
+ <td>
161
+ <span className="badge" style={{
162
+ background: `rgba(${r.z_score >= 2 ? "255,107,107" : "108,140,255"},0.15)`,
163
+ color: r.z_score >= 2 ? "var(--err)" : "var(--accent)",
164
+ }}>{r.z_score.toFixed(2)}</span>
165
+ </td>
166
+ <td style={{ fontSize: "0.85rem" }}>{r.surprising_neighbors.join(", ") || "—"}</td>
167
+ <td style={{ color: "var(--accent)", fontSize: "0.8rem" }}>inspect →</td>
168
+ </tr>
169
+ ))}
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+ )}
174
+ </div>
175
+
176
+ {/* Stage B — per-word relations drilldown */}
177
+ {selectedWord && (
178
+ <div className="panel">
179
+ <h3 style={{ marginTop: 0 }}>2 · Relations for "{selectedWord}"</h3>
180
+ {relations.loading && <StatusMessage type="loading" message="Computing relations…" />}
181
+ {relations.error && <StatusMessage type="err" message={relations.error} />}
182
+ {relations.data && !relations.data.found && (
183
+ <StatusMessage type="err" message={`"${selectedWord}" — ${relations.data.reason}.`} />
184
+ )}
185
+ {relations.data?.found && (
186
+ <>
187
+ <p className="panel-desc">
188
+ Surprise = (how strongly tied here) − (how strongly tied in general English), each
189
+ standardised within its own space. High surprise = the suspicious pairing.
190
+ </p>
191
+ <table className="data-table">
192
+ <thead>
193
+ <tr><th>Neighbour</th><th>Surprise</th><th>Corpus sim</th><th>Normal-English sim</th></tr>
194
+ </thead>
195
+ <tbody>
196
+ {relations.data.relations.map((rel) => (
197
+ <tr key={rel.neighbor}>
198
+ <td style={{ fontWeight: 600 }}>{rel.neighbor}</td>
199
+ <td><ScoreBar score={rel.surprise} max={4} /></td>
200
+ <td>{rel.corpus_sim.toFixed(3)}</td>
201
+ <td>{rel.background_sim.toFixed(3)}</td>
202
+ </tr>
203
+ ))}
204
+ </tbody>
205
+ </table>
206
+ {relations.data.normal_neighbors && (
207
+ <div className="mt-2">
208
+ <div className="section-label">For contrast — "{selectedWord}" normally relates to:</div>
209
+ <div style={{ fontSize: "0.85rem", color: "var(--muted)" }}>
210
+ {relations.data.normal_neighbors.map(n => n.neighbor).join(", ")}
211
+ </div>
212
+ </div>
213
+ )}
214
+ <button className="btn btn-primary mt-2" onClick={() => zoomIn(selectedWord, "")}>
215
+ Zoom in on occurrences →
216
+ </button>
217
+ </>
218
+ )}
219
+ </div>
220
+ )}
221
+
222
+ {/* Stage C — contextual incongruence */}
223
+ <div className="panel" id="zoom-section">
224
+ <h3 style={{ marginTop: 0 }}>3 · Zoom in — incongruent occurrences</h3>
225
+ <p className="panel-desc">
226
+ Uses the transformer to rank each occurrence of a keyword by how unlike its norm it is.
227
+ Leave the meaning blank to compare against the keyword's <em>typical</em> usage in this
228
+ corpus, or supply a dictionary meaning (e.g. "pizza, an Italian food") to flag usages that
229
+ drift from it. Highest-incongruence chunks are the candidate coded usages.
230
+ </p>
231
+ <div className="form-row">
232
+ <div className="form-group">
233
+ <label>Keyword</label>
234
+ <input value={keyword} onChange={e => setKeyword(e.target.value)}
235
+ onKeyDown={e => e.key === "Enter" && keyword.trim() && zoomIn(keyword.trim(), canonical)}
236
+ placeholder="e.g. pizza" />
237
+ </div>
238
+ <div className="form-group" style={{ flex: 2 }}>
239
+ <label>Canonical meaning (optional)</label>
240
+ <input value={canonical} onChange={e => setCanonical(e.target.value)}
241
+ onKeyDown={e => e.key === "Enter" && keyword.trim() && zoomIn(keyword.trim(), canonical)}
242
+ placeholder="leave blank to use corpus-typical usage" />
243
+ </div>
244
+ <div className="form-group form-group-sm">
245
+ <label>&nbsp;</label>
246
+ <button className="btn btn-primary" disabled={incong.loading || !keyword.trim()}
247
+ onClick={() => zoomIn(keyword.trim(), canonical)}>
248
+ {incong.loading ? "…" : "Zoom"}
249
+ </button>
250
+ </div>
251
+ </div>
252
+
253
+ {incong.error && <StatusMessage type="err" message={incong.error} />}
254
+ {incong.data && incong.data.total_occurrences === 0 && (
255
+ <StatusMessage type="err" message={`No occurrences of "${incong.data.keyword}" found.`} />
256
+ )}
257
+ {incong.data && incong.data.occurrences.length > 0 && (
258
+ <div className="mt-2">
259
+ <div className="section-label">
260
+ {incong.data.total_occurrences} occurrences · reference: {incong.data.reference} ·
261
+ median incongruence {incong.data.median_incongruence}
262
+ </div>
263
+ <div className="flex-col gap-3">
264
+ {incong.data.occurrences.map((occ, i) => (
265
+ <div key={i} className="result-card">
266
+ <div className="result-header">
267
+ <span className="context-snippet-source">{occ.doc_id} · chunk {occ.chunk_index}</span>
268
+ <span className="badge" style={{
269
+ background: "rgba(255,107,107,0.15)", color: "var(--err)",
270
+ }}>incongruence {occ.incongruence.toFixed(3)}</span>
271
+ </div>
272
+ <div className="context-snippet mt-2">{occ.snippet}</div>
273
+ {occ.entities.length > 0 && (
274
+ <div className="mt-2">
275
+ <span className="section-label">Co-occurring: </span>
276
+ {occ.entities.map((e, j) => (
277
+ <span key={j} className="badge" style={{ marginRight: 4 }}>{e}</span>
278
+ ))}
279
+ </div>
280
+ )}
281
+ </div>
282
+ ))}
283
+ </div>
284
+ </div>
285
+ )}
286
+ </div>
287
+ </div>
288
+ );
289
+ }
frontend/src/components/DatasetPanel.tsx CHANGED
@@ -10,9 +10,10 @@ import LogViewer from "./LogViewer";
10
 
11
  interface Props {
12
  onStatsUpdate?: (stats: any) => void;
 
13
  }
14
 
15
- export default function DatasetPanel({ onStatsUpdate }: Props) {
16
  const [info, setInfo] = useState<DatasetInfo | null>(null);
17
  const [error, setError] = useState("");
18
 
@@ -65,6 +66,7 @@ export default function DatasetPanel({ onStatsUpdate }: Props) {
65
  console.warn("Failed to refresh stats after load:", e);
66
  }
67
  }
 
68
  } catch (err) {
69
  setError(getErrorMessage(err));
70
  } finally {
 
10
 
11
  interface Props {
12
  onStatsUpdate?: (stats: any) => void;
13
+ onLoaded?: () => void;
14
  }
15
 
16
+ export default function DatasetPanel({ onStatsUpdate, onLoaded }: Props) {
17
  const [info, setInfo] = useState<DatasetInfo | null>(null);
18
  const [error, setError] = useState("");
19
 
 
66
  console.warn("Failed to refresh stats after load:", e);
67
  }
68
  }
69
+ onLoaded?.(); // advance to the training step
70
  } catch (err) {
71
  setError(getErrorMessage(err));
72
  } finally {
frontend/src/components/Word2VecPanel.tsx CHANGED
@@ -1,9 +1,7 @@
1
  import { useState, useEffect } from "react";
2
  import { api, getErrorMessage } from "../api";
3
- import type { W2VInitResponse } from "../types";
4
  import StatusMessage from "./StatusMessage";
5
  import LogViewer from "./LogViewer";
6
- import MetricCard from "./MetricCard";
7
 
8
  interface Props {
9
  onReady: (ready: boolean, info?: { vocab_size: number; sentences: number; vector_size: number }) => void;
@@ -11,8 +9,6 @@ interface Props {
11
 
12
  export default function Word2VecPanel({ onReady }: Props) {
13
  const [statusChecked, setStatusChecked] = useState(false);
14
- const [trainResult, setTrainResult] = useState<W2VInitResponse | null>(null);
15
-
16
  const [vectorSize, setVectorSize] = useState(100);
17
  const [windowSize, setWindowSize] = useState(5);
18
  const [w2vEpochs, setW2vEpochs] = useState(50);
@@ -30,14 +26,14 @@ export default function Word2VecPanel({ onReady }: Props) {
30
  }, []);
31
 
32
  async function handleTrainFromEngine() {
33
- setInitLoading(true); setError(""); setTrainResult(null);
34
  try {
35
  const res = await api.w2vInitFromEngine({
36
  vector_size: vectorSize,
37
  window: windowSize,
38
  epochs: w2vEpochs,
39
  });
40
- setTrainResult(res);
41
  } catch (err) {
42
  setError(getErrorMessage(err));
43
  } finally {
@@ -49,42 +45,16 @@ export default function Word2VecPanel({ onReady }: Props) {
49
  return <div className="panel"><p>Checking Word2Vec status...</p></div>;
50
  }
51
 
52
- // Training complete — show results + continue button
53
- if (trainResult) {
54
- return (
55
- <div>
56
- <div className="panel">
57
- <h2>Training Complete</h2>
58
- <div className="metric-grid">
59
- <MetricCard value={trainResult.vocab_size} label="Vocabulary" />
60
- <MetricCard value={trainResult.sentences} label="Sentences" />
61
- <MetricCard value={trainResult.vector_size} label="Dimensions" />
62
- <MetricCard value={`${trainResult.seconds}s`} label="Train Time" />
63
- </div>
64
- <StatusMessage type="ok" message="Word2Vec model trained and saved. It will persist across restarts." />
65
- <button className="btn btn-primary" style={{ marginTop: 12 }}
66
- onClick={() => onReady(true, { vocab_size: trainResult.vocab_size, sentences: trainResult.sentences, vector_size: trainResult.vector_size })}>
67
- Continue to Analysis
68
- </button>
69
- </div>
70
-
71
- <LogViewer active={false} />
72
- </div>
73
- );
74
- }
75
-
76
- // Training form
77
  return (
78
  <div>
79
  <div className="panel">
80
  <h2>Word2Vec Baseline (gensim)</h2>
81
  <p className="panel-desc">
82
- Static embeddings one vector per word, no context awareness.
83
- Train on all documents loaded in the engine to use as a baseline comparison.
84
  </p>
85
 
86
  <button className="advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced)}>
87
- {showAdvanced ? "\u25be" : "\u25b8"} Advanced Settings
88
  </button>
89
 
90
  {showAdvanced && (
@@ -100,7 +70,7 @@ export default function Word2VecPanel({ onReady }: Props) {
100
  </div>
101
  <div className="form-group" style={{ maxWidth: 120 }}>
102
  <label>Epochs</label>
103
- <input type="number" value={w2vEpochs} onChange={e => setW2vEpochs(+e.target.value)} min={5} max={200} />
104
  </div>
105
  </div>
106
  </div>
 
1
  import { useState, useEffect } from "react";
2
  import { api, getErrorMessage } from "../api";
 
3
  import StatusMessage from "./StatusMessage";
4
  import LogViewer from "./LogViewer";
 
5
 
6
  interface Props {
7
  onReady: (ready: boolean, info?: { vocab_size: number; sentences: number; vector_size: number }) => void;
 
9
 
10
  export default function Word2VecPanel({ onReady }: Props) {
11
  const [statusChecked, setStatusChecked] = useState(false);
 
 
12
  const [vectorSize, setVectorSize] = useState(100);
13
  const [windowSize, setWindowSize] = useState(5);
14
  const [w2vEpochs, setW2vEpochs] = useState(50);
 
26
  }, []);
27
 
28
  async function handleTrainFromEngine() {
29
+ setInitLoading(true); setError("");
30
  try {
31
  const res = await api.w2vInitFromEngine({
32
  vector_size: vectorSize,
33
  window: windowSize,
34
  epochs: w2vEpochs,
35
  });
36
+ onReady(true, { vocab_size: res.vocab_size, sentences: res.sentences, vector_size: res.vector_size });
37
  } catch (err) {
38
  setError(getErrorMessage(err));
39
  } finally {
 
45
  return <div className="panel"><p>Checking Word2Vec status...</p></div>;
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  return (
49
  <div>
50
  <div className="panel">
51
  <h2>Word2Vec Baseline (gensim)</h2>
52
  <p className="panel-desc">
53
+ Static embeddings trained on all documents loaded in the engine. Training runs once and persists across restarts.
 
54
  </p>
55
 
56
  <button className="advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced)}>
57
+ {showAdvanced ? "" : ""} Advanced Settings
58
  </button>
59
 
60
  {showAdvanced && (
 
70
  </div>
71
  <div className="form-group" style={{ maxWidth: 120 }}>
72
  <label>Epochs</label>
73
+ <input type="number" value={w2vEpochs} onChange={e => setW2vEpochs(+e.target.value)} min={5} max={1000} />
74
  </div>
75
  </div>
76
  </div>
frontend/src/types.ts CHANGED
@@ -297,6 +297,71 @@ export interface ContextAnalysisResponse {
297
  meanings: ContextMeaning[];
298
  }
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  // ---- UI State ----
301
 
302
  export type EvalSection = "distribution" | "disambiguation" | "retrieval";
 
297
  meanings: ContextMeaning[];
298
  }
299
 
300
+ // ---- Anomalous-relation detection ----
301
+
302
+ export interface BackgroundStatus {
303
+ model_name: string;
304
+ ready: boolean;
305
+ load_failed: boolean;
306
+ vocab_size: number;
307
+ vector_size: number;
308
+ }
309
+
310
+ export interface AnomalyWord {
311
+ word: string;
312
+ corpus_frequency: number;
313
+ shift: number;
314
+ z_score: number;
315
+ surprising_neighbors: string[];
316
+ normal_neighbors: string[];
317
+ }
318
+
319
+ export interface AnomalySweepResponse {
320
+ ready: boolean;
321
+ vocab_size: number;
322
+ neighbours?: number;
323
+ shift_mean?: number;
324
+ shift_std?: number;
325
+ note?: string;
326
+ results: AnomalyWord[];
327
+ }
328
+
329
+ export interface AnomalyRelation {
330
+ neighbor: string;
331
+ corpus_sim: number;
332
+ background_sim: number;
333
+ corpus_z: number;
334
+ background_z: number;
335
+ surprise: number;
336
+ }
337
+
338
+ export interface AnomalyRelationResponse {
339
+ word: string;
340
+ ready: boolean;
341
+ found: boolean;
342
+ reason?: string;
343
+ corpus_frequency?: number;
344
+ relations: AnomalyRelation[];
345
+ normal_neighbors?: { neighbor: string; background_sim: number }[];
346
+ }
347
+
348
+ export interface IncongruentOccurrence {
349
+ doc_id: string;
350
+ chunk_index: number;
351
+ incongruence: number;
352
+ snippet: string;
353
+ entities: string[];
354
+ }
355
+
356
+ export interface IncongruenceResponse {
357
+ keyword: string;
358
+ total_occurrences: number;
359
+ reference?: string;
360
+ reference_kind?: "gloss" | "centroid";
361
+ median_incongruence?: number;
362
+ occurrences: IncongruentOccurrence[];
363
+ }
364
+
365
  // ---- UI State ----
366
 
367
  export type EvalSection = "distribution" | "disambiguation" | "retrieval";
frontend/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/batchanalysis.tsx","./src/components/contextanalysis.tsx","./src/components/datasetpanel.tsx","./src/components/documentviewer.tsx","./src/components/enginesetup.tsx","./src/components/evaluationdashboard.tsx","./src/components/keywordanalysis.tsx","./src/components/keywordmatcher.tsx","./src/components/logviewer.tsx","./src/components/metriccard.tsx","./src/components/scorebar.tsx","./src/components/select.tsx","./src/components/semanticsearch.tsx","./src/components/similarwords.tsx","./src/components/statusmessage.tsx","./src/components/switch.tsx","./src/components/textcompare.tsx","./src/components/toggle.tsx","./src/components/trainingpanel.tsx","./src/components/word2vecpanel.tsx","./src/components/word2vectools.tsx","./src/hooks/useapicall.ts","./src/hooks/usecorpusloader.ts","./src/utils/colors.ts"],"version":"5.9.3"}
 
1
+ {"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/anomalypanel.tsx","./src/components/batchanalysis.tsx","./src/components/contextanalysis.tsx","./src/components/datasetpanel.tsx","./src/components/documentviewer.tsx","./src/components/enginesetup.tsx","./src/components/evaluationdashboard.tsx","./src/components/keywordanalysis.tsx","./src/components/keywordmatcher.tsx","./src/components/logviewer.tsx","./src/components/metriccard.tsx","./src/components/scorebar.tsx","./src/components/select.tsx","./src/components/semanticsearch.tsx","./src/components/similarwords.tsx","./src/components/statusmessage.tsx","./src/components/switch.tsx","./src/components/textcompare.tsx","./src/components/toggle.tsx","./src/components/trainingpanel.tsx","./src/components/word2vecpanel.tsx","./src/components/word2vectools.tsx","./src/hooks/useapicall.ts","./src/hooks/usecorpusloader.ts","./src/utils/colors.ts"],"version":"5.9.3"}
server.py CHANGED
@@ -34,6 +34,8 @@ from contextual_similarity import ContextualSimilarityEngine
34
  from evaluation import Evaluator, GroundTruthEntry
35
  from training import CorpusTrainer
36
  from word2vec_baseline import Word2VecEngine
 
 
37
  from data_loader import load_raw_dataset, load_raw_to_engine, import_chromadb_to_engine, get_dataset_info
38
 
39
  logging.basicConfig(level=logging.INFO)
@@ -145,6 +147,7 @@ app.add_middleware(
145
  engine: Optional[ContextualSimilarityEngine] = None
146
  evaluator: Optional[Evaluator] = None
147
  w2v_engine: Optional[Word2VecEngine] = None
 
148
 
149
  ENGINE_SAVE_DIR = Path(os.environ.get("ENGINE_STATE_DIR", str(BASE_DIR / "engine_state")))
150
  W2V_SAVE_DIR = Path(os.environ.get("W2V_STATE_DIR", str(BASE_DIR / "w2v_state")))
@@ -280,7 +283,7 @@ class W2VInitRequest(BaseModel):
280
  corpus_texts: list[str] = Field(max_length=10_000)
281
  vector_size: int = Field(default=100, ge=50, le=500)
282
  window: int = Field(default=5, ge=1, le=20)
283
- epochs: int = Field(default=50, ge=1, le=200)
284
 
285
 
286
  class W2VCompareRequest(BaseModel):
@@ -304,6 +307,26 @@ class ContextAnalysisRequest(BaseModel):
304
  top_words: int = Field(default=8, ge=1, le=30)
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  class DatasetLoadRequest(BaseModel):
308
  source: Literal["raw", "embeddings"] = "raw"
309
  max_docs: int = Field(default=500, ge=1, le=100_000)
@@ -521,6 +544,69 @@ def match_keyword(req: KeywordMatchRequest):
521
  ]}
522
 
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  # ------------------------------------------------------------------ #
525
  # Evaluation endpoints
526
  # ------------------------------------------------------------------ #
@@ -656,7 +742,7 @@ def w2v_init(req: W2VInitRequest):
656
  def w2v_init_from_engine(
657
  vector_size: int = Query(default=100, ge=50, le=500),
658
  window: int = Query(default=5, ge=1, le=20),
659
- epochs: int = Query(default=50, ge=1, le=200),
660
  ):
661
  """Train Word2Vec directly from all documents already loaded in the engine.
662
 
@@ -729,18 +815,6 @@ def w2v_status():
729
  return {"ready": False, "has_saved_state": has_saved}
730
 
731
 
732
- @app.post("/api/w2v/reset")
733
- def w2v_reset():
734
- """Delete saved Word2Vec state and clear the in-memory model."""
735
- global w2v_engine
736
- w2v_engine = None
737
- import shutil
738
- if W2V_SAVE_DIR.is_dir():
739
- shutil.rmtree(str(W2V_SAVE_DIR))
740
- logger.info("Word2Vec state deleted from %s", W2V_SAVE_DIR)
741
- return {"status": "ok", "message": "Word2Vec state cleared. You can retrain now."}
742
-
743
-
744
  # ------------------------------------------------------------------ #
745
  # Dataset endpoints (HuggingFace Epstein Files)
746
  # ------------------------------------------------------------------ #
@@ -843,6 +917,10 @@ def _ensure_w2v():
843
  if w2v_engine is None:
844
  raise HTTPException(400, "Word2Vec not initialized. POST /api/w2v/init first.")
845
 
 
 
 
 
846
  def _serialize_analysis(analysis):
847
  return {
848
  "keyword": analysis.keyword,
 
34
  from evaluation import Evaluator, GroundTruthEntry
35
  from training import CorpusTrainer
36
  from word2vec_baseline import Word2VecEngine
37
+ from background_model import BackgroundModel
38
+ from anomaly import sweep_anomalous_words, relation_surprise, contextual_incongruence
39
  from data_loader import load_raw_dataset, load_raw_to_engine, import_chromadb_to_engine, get_dataset_info
40
 
41
  logging.basicConfig(level=logging.INFO)
 
147
  engine: Optional[ContextualSimilarityEngine] = None
148
  evaluator: Optional[Evaluator] = None
149
  w2v_engine: Optional[Word2VecEngine] = None
150
+ background_model = BackgroundModel() # general-English reference (lazy-loaded)
151
 
152
  ENGINE_SAVE_DIR = Path(os.environ.get("ENGINE_STATE_DIR", str(BASE_DIR / "engine_state")))
153
  W2V_SAVE_DIR = Path(os.environ.get("W2V_STATE_DIR", str(BASE_DIR / "w2v_state")))
 
283
  corpus_texts: list[str] = Field(max_length=10_000)
284
  vector_size: int = Field(default=100, ge=50, le=500)
285
  window: int = Field(default=5, ge=1, le=20)
286
+ epochs: int = Field(default=50, ge=1, le=1000)
287
 
288
 
289
  class W2VCompareRequest(BaseModel):
 
307
  top_words: int = Field(default=8, ge=1, le=30)
308
 
309
 
310
+ class AnomalySweepRequest(BaseModel):
311
+ min_count: int = Field(default=5, ge=1, le=1000)
312
+ max_vocab: int = Field(default=3000, ge=100, le=20_000)
313
+ neighbours: int = Field(default=25, ge=5, le=100)
314
+ top_n: int = Field(default=30, ge=1, le=200)
315
+
316
+
317
+ class AnomalyRelationRequest(BaseModel):
318
+ word: str = Field(max_length=200)
319
+ min_count: int = Field(default=5, ge=1, le=1000)
320
+ max_vocab: int = Field(default=3000, ge=100, le=20_000)
321
+ top_k: int = Field(default=15, ge=1, le=100)
322
+
323
+
324
+ class IncongruenceRequest(BaseModel):
325
+ keyword: str = Field(max_length=200)
326
+ canonical_meaning: Optional[str] = Field(default=None, max_length=500)
327
+ top_k: int = Field(default=10, ge=1, le=100)
328
+
329
+
330
  class DatasetLoadRequest(BaseModel):
331
  source: Literal["raw", "embeddings"] = "raw"
332
  max_docs: int = Field(default=500, ge=1, le=100_000)
 
544
  ]}
545
 
546
 
547
+ # ------------------------------------------------------------------ #
548
+ # Anomalous-relation detection (code-word candidates)
549
+ # ------------------------------------------------------------------ #
550
+
551
+ @app.get("/api/background/status")
552
+ def background_status():
553
+ """Status of the general-English background model used for anomaly detection."""
554
+ return background_model.status()
555
+
556
+
557
+ @app.post("/api/background/load")
558
+ def background_load():
559
+ """Eagerly load the background model (otherwise it loads on first anomaly query)."""
560
+ ok = background_model.load()
561
+ if not ok:
562
+ raise HTTPException(503, "Background model failed to load. Check server logs (network/disk).")
563
+ return background_model.status()
564
+
565
+
566
+ @app.post("/api/analyze/anomalies")
567
+ def analyze_anomalies(req: AnomalySweepRequest):
568
+ """Stage A: rank corpus words by how differently they associate here vs. normal English."""
569
+ _ensure_w2v()
570
+ _ensure_background()
571
+ logger.info("Anomaly sweep: min_count=%d, max_vocab=%d, neighbours=%d, top_n=%d",
572
+ req.min_count, req.max_vocab, req.neighbours, req.top_n)
573
+ t0 = time.time()
574
+ result = sweep_anomalous_words(
575
+ w2v_engine, background_model,
576
+ min_count=req.min_count, max_vocab=req.max_vocab,
577
+ neighbours=req.neighbours, top_n=req.top_n,
578
+ )
579
+ logger.info("Anomaly sweep complete: %d words ranked in %.1fs",
580
+ len(result.get("results", [])), time.time() - t0)
581
+ return _to_native(result)
582
+
583
+
584
+ @app.post("/api/analyze/anomaly-relations")
585
+ def analyze_anomaly_relations(req: AnomalyRelationRequest):
586
+ """Stage B: for one word, the neighbours strong here but weak/absent in general English."""
587
+ _ensure_w2v()
588
+ _ensure_background()
589
+ logger.info("Anomaly relations: word='%s', top_k=%d", req.word, req.top_k)
590
+ result = relation_surprise(
591
+ req.word, w2v_engine, background_model,
592
+ min_count=req.min_count, max_vocab=req.max_vocab, top_k=req.top_k,
593
+ )
594
+ return _to_native(result)
595
+
596
+
597
+ @app.post("/api/analyze/incongruence")
598
+ def analyze_incongruence(req: IncongruenceRequest):
599
+ """Stage C: occurrences where a keyword is used most unlike its norm (transformer-based)."""
600
+ _ensure_engine(); _ensure_index()
601
+ logger.info("Incongruence: keyword='%s', canonical=%s, top_k=%d",
602
+ req.keyword, bool(req.canonical_meaning), req.top_k)
603
+ result = contextual_incongruence(
604
+ engine, req.keyword,
605
+ canonical_meaning=req.canonical_meaning, top_k=req.top_k,
606
+ )
607
+ return _to_native(result)
608
+
609
+
610
  # ------------------------------------------------------------------ #
611
  # Evaluation endpoints
612
  # ------------------------------------------------------------------ #
 
742
  def w2v_init_from_engine(
743
  vector_size: int = Query(default=100, ge=50, le=500),
744
  window: int = Query(default=5, ge=1, le=20),
745
+ epochs: int = Query(default=50, ge=1, le=1000),
746
  ):
747
  """Train Word2Vec directly from all documents already loaded in the engine.
748
 
 
815
  return {"ready": False, "has_saved_state": has_saved}
816
 
817
 
 
 
 
 
 
 
 
 
 
 
 
 
818
  # ------------------------------------------------------------------ #
819
  # Dataset endpoints (HuggingFace Epstein Files)
820
  # ------------------------------------------------------------------ #
 
917
  if w2v_engine is None:
918
  raise HTTPException(400, "Word2Vec not initialized. POST /api/w2v/init first.")
919
 
920
+ def _ensure_background():
921
+ if not background_model.load():
922
+ raise HTTPException(503, "Background model unavailable (download/load failed). Check server logs.")
923
+
924
  def _serialize_analysis(analysis):
925
  return {
926
  "keyword": analysis.keyword,