uzzam2121 commited on
Commit
fc7db98
·
1 Parent(s): 5353eed

Fix build: bundle code in Space repo

Browse files
Dockerfile CHANGED
@@ -1,22 +1,22 @@
1
- # Hugging Face Space — clones GitHub repo at build time.
2
- # Push this file + README.md to: uzzam24/complexity-levels-api
3
  FROM python:3.11-slim
4
 
5
  RUN apt-get update && apt-get install -y --no-install-recommends \
6
- build-essential git \
7
  && rm -rf /var/lib/apt/lists/*
8
 
9
  RUN useradd -m -u 1000 user
10
 
11
- WORKDIR /app
12
 
13
- RUN git clone --depth 1 https://github.com/muhammad245/complexityLevels.git /app/repo
14
-
15
- RUN pip install --no-cache-dir -r /app/repo/deploy/backend/requirements.txt huggingface_hub \
16
  && python -m spacy download en_core_web_sm
17
 
18
- RUN chmod +x /app/repo/deploy/huggingface/entrypoint.sh \
19
- && chown -R user:user /app
 
20
 
21
  ENV MODEL_PATH=/app/model
22
  ENV HF_MODEL_REPO=uzzam24/deberta-complexity-lcp
 
1
+ # Hugging Face Space — all code copied into Space repo (no GitHub clone needed).
2
+ # Run sync_to_space.ps1 from project root, then git push the Space repo.
3
  FROM python:3.11-slim
4
 
5
  RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
  && rm -rf /var/lib/apt/lists/*
8
 
9
  RUN useradd -m -u 1000 user
10
 
11
+ WORKDIR /app/repo
12
 
13
+ COPY src/deploy/backend/requirements.txt /tmp/requirements.txt
14
+ RUN pip install --no-cache-dir -r /tmp/requirements.txt huggingface_hub \
 
15
  && python -m spacy download en_core_web_sm
16
 
17
+ COPY --chown=user:user src/ /app/repo/
18
+
19
+ RUN chmod +x /app/repo/deploy/huggingface/entrypoint.sh
20
 
21
  ENV MODEL_PATH=/app/model
22
  ENV HF_MODEL_REPO=uzzam24/deberta-complexity-lcp
src/deploy/backend/app/__init__.py ADDED
File without changes
src/deploy/backend/app/config.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend configuration — model path and CORS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ # Repo root (Complexity_level/) when running locally or in Docker /app
9
+ REPO_ROOT = Path(__file__).resolve().parents[3]
10
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def resolve_model_path() -> Path:
14
+ """MODEL_PATH env, then overnight_bundle, then exported_models."""
15
+ if env := os.environ.get("MODEL_PATH"):
16
+ return Path(env).expanduser().resolve()
17
+
18
+ candidates = [
19
+ REPO_ROOT / "model",
20
+ Path("/app/model"),
21
+ REPO_ROOT / "overnight_bundle" / "exported_models" / "deberta_best",
22
+ REPO_ROOT / "exported_models" / "deberta_best",
23
+ Path("/app/overnight_bundle/exported_models/deberta_best"),
24
+ Path("/app/exported_models/deberta_best"),
25
+ ]
26
+ for path in candidates:
27
+ if (path / "model_weights.pt").exists():
28
+ return path
29
+ # Return first candidate for error messaging even if weights missing
30
+ return candidates[0]
31
+
32
+
33
+ MODEL_PATH = resolve_model_path()
34
+ CORS_ORIGINS = [
35
+ o.strip()
36
+ for o in os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
37
+ if o.strip()
38
+ ]
39
+ PORT = int(os.environ.get("PORT", "8000"))
src/deploy/backend/app/main.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI backend for Railway — word complexity inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ from app.config import CORS_ORIGINS, MODEL_PATH
12
+ from app.schemas import (
13
+ EfficiencyRequest,
14
+ EfficiencyResponse,
15
+ HealthResponse,
16
+ InterveneAllRequest,
17
+ InterveneAllResponse,
18
+ InterveneRequest,
19
+ InterveneResponse,
20
+ PredictRequest,
21
+ PredictResponse,
22
+ )
23
+ from app.services.efficiency import compare_efficiency
24
+ from app.services.interventions import run_intervention
25
+ from app.services.model import get_predictor, model_status, predict
26
+
27
+
28
+ @asynccontextmanager
29
+ async def lifespan(_app: FastAPI):
30
+ if os.environ.get("EAGER_LOAD_MODEL", "1") == "1":
31
+ try:
32
+ get_predictor()
33
+ except RuntimeError as exc:
34
+ print(f"WARN: model not loaded at startup: {exc}")
35
+ yield
36
+
37
+
38
+ app = FastAPI(title="Word Complexity API", version="1.0.0", lifespan=lifespan)
39
+
40
+ app.add_middleware(
41
+ CORSMiddleware,
42
+ allow_origins=["*"] if os.environ.get("CORS_ALLOW_ALL") == "1" else CORS_ORIGINS,
43
+ allow_credentials=os.environ.get("CORS_ALLOW_ALL") != "1",
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+
49
+ @app.get("/")
50
+ def root():
51
+ ready, err = model_status()
52
+ return {
53
+ "service": "Word Complexity API",
54
+ "health": "/health",
55
+ "docs": "/docs",
56
+ "model_ready": ready,
57
+ "model_error": err,
58
+ }
59
+
60
+
61
+ @app.get("/health", response_model=HealthResponse)
62
+ def health():
63
+ ready, err = model_status()
64
+ return HealthResponse(
65
+ status="ok" if ready else "degraded",
66
+ model_path=str(MODEL_PATH),
67
+ model_ready=ready,
68
+ model_error=err,
69
+ )
70
+
71
+
72
+ @app.post("/api/predict", response_model=PredictResponse)
73
+ def api_predict(body: PredictRequest):
74
+ try:
75
+ data, latency_ms, target_in = predict(body.sentence.strip(), body.target_word.strip())
76
+ except RuntimeError as exc:
77
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
78
+ return PredictResponse(**data, latency_ms=latency_ms, target_in_sentence=target_in)
79
+
80
+
81
+ @app.post("/api/intervene", response_model=InterveneResponse)
82
+ def api_intervene(body: InterveneRequest):
83
+ try:
84
+ predictor = get_predictor()
85
+ detail = run_intervention(
86
+ predictor,
87
+ body.sentence.strip(),
88
+ body.target_word.strip(),
89
+ body.reason,
90
+ body.use_llm,
91
+ )
92
+ except RuntimeError as exc:
93
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
94
+ except ValueError as exc:
95
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
96
+ return InterveneResponse(intervention=detail)
97
+
98
+
99
+ @app.post("/api/intervene/all", response_model=InterveneAllResponse)
100
+ def api_intervene_all(body: InterveneAllRequest):
101
+ try:
102
+ predictor = get_predictor()
103
+ base = predictor.predict(body.sentence.strip(), body.target_word.strip())
104
+ from utils import REASON_ORDER
105
+
106
+ results = [
107
+ run_intervention(
108
+ predictor,
109
+ body.sentence.strip(),
110
+ body.target_word.strip(),
111
+ reason,
112
+ body.use_llm,
113
+ predicted_reason=body.predicted_reason,
114
+ )
115
+ for reason in REASON_ORDER
116
+ ]
117
+ except RuntimeError as exc:
118
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
119
+
120
+ best = max(results, key=lambda r: r.hardness_drop)
121
+ faithful = None
122
+ if body.predicted_reason:
123
+ faithful = best.reason == body.predicted_reason
124
+
125
+ return InterveneAllResponse(
126
+ base_level=base.complexity_level,
127
+ results=results,
128
+ best_reason=best.reason,
129
+ faithful=faithful,
130
+ )
131
+
132
+
133
+ @app.post("/api/efficiency", response_model=EfficiencyResponse)
134
+ def api_efficiency(body: EfficiencyRequest):
135
+ return compare_efficiency(body.sentence.strip(), body.target_word.strip(), body.local_latency_ms)
src/deploy/backend/app/schemas.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class PredictRequest(BaseModel):
7
+ sentence: str = Field(..., min_length=1)
8
+ target_word: str = Field(..., min_length=1)
9
+
10
+
11
+ class PredictResponse(BaseModel):
12
+ complexity_level: str
13
+ level_id: int
14
+ level_probs: dict[str, float]
15
+ difficult_class_prob: float
16
+ reason: str | None
17
+ reason_id: int | None
18
+ reason_probs: dict[str, float] | None
19
+ latency_ms: float
20
+ target_in_sentence: bool
21
+
22
+
23
+ class InterveneRequest(BaseModel):
24
+ sentence: str
25
+ target_word: str
26
+ reason: str
27
+ use_llm: bool = False
28
+
29
+
30
+ class InterventionDetail(BaseModel):
31
+ reason: str
32
+ original_sentence: str
33
+ edited_sentence: str
34
+ edit_method: str
35
+ before_level: str
36
+ after_level: str
37
+ hardness_drop: float
38
+ matches_predicted: bool | None = None
39
+
40
+
41
+ class InterveneResponse(BaseModel):
42
+ intervention: InterventionDetail
43
+
44
+
45
+ class InterveneAllRequest(BaseModel):
46
+ sentence: str
47
+ target_word: str
48
+ predicted_reason: str | None = None
49
+ use_llm: bool = False
50
+
51
+
52
+ class InterveneAllResponse(BaseModel):
53
+ base_level: str
54
+ results: list[InterventionDetail]
55
+ best_reason: str
56
+ faithful: bool | None
57
+
58
+
59
+ class EfficiencyRequest(BaseModel):
60
+ sentence: str
61
+ target_word: str
62
+ local_latency_ms: float = Field(..., ge=0)
63
+
64
+
65
+ class EfficiencyResponse(BaseModel):
66
+ local_latency_ms: float
67
+ ai_latency_ms: float
68
+ ai_provider: str
69
+ ai_cost_usd: float
70
+ ai_cost_per_1k_usd: float
71
+ local_cost_usd: float
72
+ speedup_factor: float
73
+
74
+
75
+ class HealthResponse(BaseModel):
76
+ status: str
77
+ model_path: str
78
+ model_ready: bool
79
+ model_error: str | None = None
src/deploy/backend/app/services/__init__.py ADDED
File without changes
src/deploy/backend/app/services/efficiency.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from app.config import REPO_ROOT
6
+ from app.schemas import EfficiencyResponse
7
+
8
+
9
+ def _ensure_path() -> None:
10
+ if str(REPO_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(REPO_ROOT))
12
+
13
+
14
+ def compare_efficiency(sentence: str, target_word: str, local_latency_ms: float) -> EfficiencyResponse:
15
+ _ensure_path()
16
+ from ui.efficiency_panel import compare_query
17
+
18
+ cmp = compare_query(sentence, target_word, local_latency_ms)
19
+ return EfficiencyResponse(
20
+ local_latency_ms=cmp.local_latency_ms,
21
+ ai_latency_ms=cmp.ai_latency_ms,
22
+ ai_provider=cmp.ai_provider,
23
+ ai_cost_usd=cmp.ai_cost_usd,
24
+ ai_cost_per_1k_usd=cmp.ai_cost_per_1k_usd,
25
+ local_cost_usd=cmp.local_cost_usd,
26
+ speedup_factor=cmp.speedup_factor,
27
+ )
src/deploy/backend/app/services/interventions.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from app.config import REPO_ROOT
6
+ from app.schemas import InterventionDetail
7
+
8
+
9
+ def _ensure_path() -> None:
10
+ if str(REPO_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(REPO_ROOT))
12
+
13
+
14
+ def run_intervention(
15
+ predictor,
16
+ sentence: str,
17
+ target_word: str,
18
+ reason: str,
19
+ use_llm: bool,
20
+ predicted_reason: str | None = None,
21
+ ) -> InterventionDetail:
22
+ _ensure_path()
23
+ from reason_interventions import apply_intervention
24
+
25
+ base = predictor.predict(sentence, target_word)
26
+ intervention = apply_intervention(reason, sentence, target_word, use_llm=use_llm)
27
+ after = predictor.predict(intervention.edited_sentence, target_word)
28
+ drop = base.difficult_class_prob - after.difficult_class_prob
29
+ return InterventionDetail(
30
+ reason=reason,
31
+ original_sentence=intervention.original_sentence,
32
+ edited_sentence=intervention.edited_sentence,
33
+ edit_method=intervention.edit_method,
34
+ before_level=base.complexity_level,
35
+ after_level=after.complexity_level,
36
+ hardness_drop=round(drop, 4),
37
+ matches_predicted=reason == predicted_reason if predicted_reason else None,
38
+ )
src/deploy/backend/app/services/model.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy-load ComplexityPredictor from repo inference code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from app.config import MODEL_PATH, REPO_ROOT
10
+
11
+ _predictor = None
12
+ _load_error: str | None = None
13
+
14
+
15
+ def _ensure_inference_path() -> None:
16
+ if str(REPO_ROOT) not in sys.path:
17
+ sys.path.insert(0, str(REPO_ROOT))
18
+
19
+
20
+ def get_predictor():
21
+ global _predictor, _load_error
22
+ if _predictor is not None:
23
+ return _predictor
24
+ if _load_error is not None:
25
+ raise RuntimeError(_load_error)
26
+
27
+ _ensure_inference_path()
28
+ weights = MODEL_PATH / "model_weights.pt"
29
+ if not weights.exists():
30
+ _load_error = (
31
+ f"Model weights not found at {weights}. "
32
+ "Set MODEL_PATH or place deberta_best under overnight_bundle/exported_models/."
33
+ )
34
+ raise RuntimeError(_load_error)
35
+
36
+ from predictor import ComplexityPredictor
37
+
38
+ t0 = time.perf_counter()
39
+ _predictor = ComplexityPredictor.load(MODEL_PATH)
40
+ print(f"Model loaded from {MODEL_PATH} in {(time.perf_counter() - t0):.1f}s")
41
+ return _predictor
42
+
43
+
44
+ def model_status() -> tuple[bool, str | None]:
45
+ try:
46
+ get_predictor()
47
+ return True, None
48
+ except Exception as exc:
49
+ return False, str(exc)
50
+
51
+
52
+ def predict(sentence: str, target_word: str) -> tuple[dict, float, bool]:
53
+ predictor = get_predictor()
54
+ t0 = time.perf_counter()
55
+ result = predictor.predict(sentence, target_word)
56
+ latency_ms = (time.perf_counter() - t0) * 1000
57
+ target_in = target_word in sentence
58
+ return result.to_dict(), latency_ms, target_in
src/deploy/backend/requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.27.0
3
+ python-multipart>=0.0.9
4
+ torch>=2.0.0
5
+ transformers>=4.36.0
6
+ numpy>=1.24.0
7
+ sentencepiece>=0.1.99
8
+ spacy>=3.7.0
9
+ wordfreq>=3.0.0
10
+ nltk>=3.8.0
11
+ openai>=1.0.0
src/deploy/huggingface/download_model.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download exported model from Hugging Face Hub before API startup.
3
+
4
+ Set Space secret / env:
5
+ HF_MODEL_REPO=your-username/deberta-complexity-lcp
6
+ MODEL_PATH=/app/model
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ def main() -> None:
17
+ repo = os.environ.get("HF_MODEL_REPO", "").strip()
18
+ target = Path(os.environ.get("MODEL_PATH", "/app/model"))
19
+
20
+ if not repo:
21
+ print("HF_MODEL_REPO not set — expecting model_weights.pt already on disk.")
22
+ weights = target / "model_weights.pt"
23
+ if weights.exists():
24
+ print(f"Found {weights}")
25
+ else:
26
+ print(f"WARN: no weights at {weights}", file=sys.stderr)
27
+ return
28
+
29
+ weights = target / "model_weights.pt"
30
+ if weights.exists():
31
+ print(f"Model already present at {weights}")
32
+ return
33
+
34
+ print(f"Downloading {repo} → {target}")
35
+ from huggingface_hub import snapshot_download
36
+
37
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
38
+
39
+ target.mkdir(parents=True, exist_ok=True)
40
+ snapshot_download(
41
+ repo_id=repo,
42
+ local_dir=str(target),
43
+ token=token,
44
+ allow_patterns=[
45
+ "model_weights.pt",
46
+ "config.json",
47
+ "tokenizer/*",
48
+ "*.json",
49
+ ],
50
+ )
51
+
52
+ if not weights.exists():
53
+ raise FileNotFoundError(
54
+ f"Download finished but {weights} missing. "
55
+ f"Upload model with: python deploy/huggingface/upload_model_to_hub.py"
56
+ )
57
+ print(f"Ready: {weights} ({weights.stat().st_size / 1e6:.1f} MB)")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
src/deploy/huggingface/entrypoint.sh ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+ ROOT="${APP_ROOT:-/app}"
4
+ python "$ROOT/deploy/huggingface/download_model.py"
5
+ exec uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-7860}" --app-dir "$ROOT/deploy/backend"
src/linguistic_features.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Handcrafted linguistic features for hybrid LCP models.
3
+
4
+ Literature: word frequency is the strongest single predictor; WordNet senses
5
+ capture ambiguity; syntax score captures structural difficulty.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from functools import lru_cache
12
+
13
+ import numpy as np
14
+ import wordfreq
15
+
16
+ from syntax_complexity import analyze_syntax
17
+
18
+ FEATURE_NAMES = [
19
+ "log_word_frequency",
20
+ "word_length",
21
+ "syllable_estimate",
22
+ "wordnet_senses",
23
+ "syntax_complexity",
24
+ ]
25
+
26
+ N_LINGUISTIC_FEATURES = len(FEATURE_NAMES)
27
+
28
+
29
+ @lru_cache(maxsize=1)
30
+ def _wordnet_ready() -> bool:
31
+ try:
32
+ from nltk.corpus import wordnet as wn
33
+
34
+ wn.synsets("test")
35
+ return True
36
+ except LookupError:
37
+ import nltk
38
+
39
+ nltk.download("wordnet", quiet=True)
40
+ nltk.download("omw-1.4", quiet=True)
41
+ return True
42
+
43
+
44
+ def _syllable_estimate(word: str) -> float:
45
+ word = word.lower()
46
+ if not word:
47
+ return 0.0
48
+ vowels = "aeiouy"
49
+ count = 0
50
+ prev_vowel = False
51
+ for ch in word:
52
+ is_vowel = ch in vowels
53
+ if is_vowel and not prev_vowel:
54
+ count += 1
55
+ prev_vowel = is_vowel
56
+ return float(max(1, count))
57
+
58
+
59
+ def _wordnet_sense_count(word: str) -> float:
60
+ _wordnet_ready()
61
+ from nltk.corpus import wordnet as wn
62
+
63
+ return float(len(wn.synsets(word.lower())))
64
+
65
+
66
+ def extract_linguistic_features(sentence: str, target_word: str) -> np.ndarray:
67
+ """Return a 5-dim feature vector for one (sentence, target_word) pair."""
68
+ word = str(target_word).strip()
69
+ freq = wordfreq.word_frequency(word.lower(), "en")
70
+ log_freq = math.log10(freq + 1e-12)
71
+ word_len = len(word)
72
+ syllables = _syllable_estimate(word)
73
+ senses = _wordnet_sense_count(word)
74
+ syntax = analyze_syntax(str(sentence)).complexity_score
75
+ return np.array([log_freq, word_len, syllables, senses, syntax], dtype=np.float32)
76
+
77
+
78
+ def normalize_features(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
79
+ """Z-score normalize feature matrix; return normalized, mean, std."""
80
+ mean = matrix.mean(axis=0)
81
+ std = matrix.std(axis=0)
82
+ std = np.where(std < 1e-6, 1.0, std)
83
+ return (matrix - mean) / std, mean, std
src/model_loading.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load trained LCP checkpoints with backward compatibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+ from transformers import AutoTokenizer
10
+
11
+ from two_head_model import TwoHeadModel, add_tgt_tokens
12
+ from utils import ENCODING_SPAN_MARK, MODELS, POOLING_CLS_CONCAT, POOLING_SPAN
13
+
14
+
15
+ def load_model_from_checkpoint(ckpt_path: str | Path, device: torch.device | None = None):
16
+ ckpt_path = Path(ckpt_path)
17
+ device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
18
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
19
+
20
+ model_key = ckpt.get("model_key", "deberta")
21
+ encoding = ckpt.get("encoding", ENCODING_SPAN_MARK)
22
+ pooling = ckpt.get("pooling", POOLING_CLS_CONCAT) # legacy checkpoints
23
+ use_linguistic = ckpt.get("use_linguistic_features", False)
24
+ level_only = ckpt.get("level_only", False)
25
+
26
+ tokenizer = AutoTokenizer.from_pretrained(MODELS[model_key])
27
+ tgt_id, tgt_end_id = add_tgt_tokens(tokenizer)
28
+
29
+ model = TwoHeadModel(
30
+ model_name=MODELS[model_key],
31
+ pooling_mode=pooling,
32
+ use_linguistic_features=use_linguistic,
33
+ )
34
+ model.encoder.resize_token_embeddings(len(tokenizer))
35
+ model.load_state_dict(ckpt["model_state"])
36
+ model.set_tgt_token_ids(ckpt.get("tgt_id", tgt_id), ckpt.get("tgt_end_id", tgt_end_id))
37
+ model.to(device)
38
+ model.eval()
39
+
40
+ feat_mean = np.array(ckpt["feat_mean"]) if ckpt.get("feat_mean") is not None else None
41
+ feat_std = np.array(ckpt["feat_std"]) if ckpt.get("feat_std") is not None else None
42
+
43
+ meta = {
44
+ "model_key": model_key,
45
+ "encoding": encoding,
46
+ "pooling": pooling,
47
+ "use_linguistic_features": use_linguistic,
48
+ "level_only": level_only,
49
+ "tgt_id": ckpt.get("tgt_id", tgt_id),
50
+ "tgt_end_id": ckpt.get("tgt_end_id", tgt_end_id),
51
+ "feat_mean": feat_mean,
52
+ "feat_std": feat_std,
53
+ }
54
+ return model, tokenizer, meta
src/predictor.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI-ready inference wrapper for the two-head complexity model.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import pickle
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+ from linguistic_features import extract_linguistic_features
17
+ from model_loading import load_model_from_checkpoint
18
+ from utils import (
19
+ EXPORT_DIR,
20
+ HARD_LEVELS,
21
+ ID_TO_LEVEL,
22
+ ID_TO_REASON,
23
+ LEVEL_ORDER,
24
+ MODELS,
25
+ REASON_ORDER,
26
+ difficult_class_probability,
27
+ tokenize_lcp_input,
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class PredictionResult:
33
+ complexity_level: str
34
+ level_id: int
35
+ level_probs: dict[str, float]
36
+ difficult_class_prob: float # P(Hard)+P(Very Hard) from 5-class softmax (auxiliary)
37
+ reason: str | None
38
+ reason_id: int | None
39
+ reason_probs: dict[str, float] | None
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return asdict(self)
43
+
44
+
45
+ class ComplexityPredictor:
46
+ """Load once, call predict() from your UI or API."""
47
+
48
+ def __init__(
49
+ self,
50
+ model,
51
+ tokenizer,
52
+ meta: dict,
53
+ max_length: int = 192,
54
+ device: str | None = None,
55
+ ):
56
+ self.model = model
57
+ self.tokenizer = tokenizer
58
+ self.meta = meta
59
+ self.model_key = meta["model_key"]
60
+ self.encoding = meta.get("encoding", "span_mark")
61
+ self.max_length = max_length
62
+ self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
63
+ self.model.to(self.device)
64
+ self.model.eval()
65
+
66
+ @classmethod
67
+ def from_checkpoint(cls, checkpoint_path: str | Path, device: str | None = None) -> "ComplexityPredictor":
68
+ dev = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
69
+ model, tokenizer, meta = load_model_from_checkpoint(checkpoint_path, dev)
70
+ return cls(model, tokenizer, meta, device=str(dev))
71
+
72
+ @classmethod
73
+ def load(cls, export_dir: str | Path, device: str | None = None) -> "ComplexityPredictor":
74
+ export_dir = Path(export_dir)
75
+ with open(export_dir / "config.json") as f:
76
+ config = json.load(f)
77
+
78
+ from transformers import AutoTokenizer
79
+ from two_head_model import TwoHeadModel
80
+
81
+ model_key = config["model_key"]
82
+ tokenizer = AutoTokenizer.from_pretrained(export_dir / "tokenizer")
83
+ model = TwoHeadModel(
84
+ model_name=MODELS[model_key],
85
+ pooling_mode=config.get("pooling", "span"),
86
+ use_linguistic_features=config.get("use_linguistic_features", False),
87
+ )
88
+ model.encoder.resize_token_embeddings(len(tokenizer))
89
+ weights = torch.load(export_dir / "model_weights.pt", map_location="cpu", weights_only=True)
90
+ model.load_state_dict(weights)
91
+ model.set_tgt_token_ids(config["tgt_id"], config.get("tgt_end_id"))
92
+
93
+ meta = {
94
+ "model_key": model_key,
95
+ "encoding": config.get("encoding", "span_mark"),
96
+ "pooling": config.get("pooling", "span"),
97
+ "use_linguistic_features": config.get("use_linguistic_features", False),
98
+ "tgt_id": config["tgt_id"],
99
+ "tgt_end_id": config.get("tgt_end_id"),
100
+ "feat_mean": np.array(config["feat_mean"]) if config.get("feat_mean") else None,
101
+ "feat_std": np.array(config["feat_std"]) if config.get("feat_std") else None,
102
+ }
103
+ return cls(model, tokenizer, meta, max_length=config.get("max_length", 192), device=device)
104
+
105
+ def save(self, export_dir: str | Path) -> Path:
106
+ export_dir = Path(export_dir)
107
+ export_dir.mkdir(parents=True, exist_ok=True)
108
+
109
+ config = {
110
+ "model_key": self.model_key,
111
+ "model_name": MODELS[self.model_key],
112
+ "tgt_id": self.meta.get("tgt_id"),
113
+ "tgt_end_id": self.meta.get("tgt_end_id"),
114
+ "encoding": self.encoding,
115
+ "pooling": self.meta.get("pooling", "span"),
116
+ "use_linguistic_features": self.meta.get("use_linguistic_features", False),
117
+ "feat_mean": self.meta.get("feat_mean").tolist() if self.meta.get("feat_mean") is not None else None,
118
+ "feat_std": self.meta.get("feat_std").tolist() if self.meta.get("feat_std") is not None else None,
119
+ "max_length": self.max_length,
120
+ "level_labels": LEVEL_ORDER,
121
+ "reason_labels": REASON_ORDER,
122
+ "hard_levels": list(HARD_LEVELS),
123
+ "inference_rule": "Reason is returned only when predicted level is Hard or Very Hard.",
124
+ "task": "5_class_complexity_prediction",
125
+ "outputs": "discrete_level_and_optional_reason_class",
126
+ }
127
+ with open(export_dir / "config.json", "w") as f:
128
+ json.dump(config, f, indent=2)
129
+
130
+ torch.save(self.model.state_dict(), export_dir / "model_weights.pt")
131
+ self.tokenizer.save_pretrained(export_dir / "tokenizer")
132
+ return export_dir
133
+
134
+ def save_pickle(self, pickle_path: str | Path) -> Path:
135
+ pickle_path = Path(pickle_path)
136
+ pickle_path.parent.mkdir(parents=True, exist_ok=True)
137
+ with open(pickle_path, "wb") as f:
138
+ pickle.dump(self, f)
139
+ return pickle_path
140
+
141
+ @classmethod
142
+ def load_pickle(cls, pickle_path: str | Path, device: str | None = None) -> "ComplexityPredictor":
143
+ with open(pickle_path, "rb") as f:
144
+ obj = pickle.load(f)
145
+ if not isinstance(obj, cls):
146
+ raise TypeError(f"Expected ComplexityPredictor, got {type(obj)}")
147
+ if device:
148
+ obj.device = torch.device(device)
149
+ obj.model.to(obj.device)
150
+ obj.model.eval()
151
+ return obj
152
+
153
+ @torch.no_grad()
154
+ def predict(self, sentence: str, target_word: str, corpus: str | None = None) -> PredictionResult:
155
+ enc = tokenize_lcp_input(
156
+ self.tokenizer,
157
+ sentence,
158
+ target_word,
159
+ encoding=self.encoding,
160
+ max_length=self.max_length,
161
+ corpus=corpus,
162
+ )
163
+ enc = {k: v.to(self.device) for k, v in enc.items()}
164
+
165
+ kwargs = {"input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"]}
166
+ if self.meta.get("use_linguistic_features"):
167
+ feats = extract_linguistic_features(sentence, target_word)
168
+ if self.meta.get("feat_mean") is not None:
169
+ feats = (feats - self.meta["feat_mean"]) / self.meta["feat_std"]
170
+ kwargs["linguistic_features"] = torch.tensor(feats, dtype=torch.float32).unsqueeze(0).to(self.device)
171
+
172
+ out = self.model(**kwargs)
173
+ level_probs_t = torch.softmax(out.level_logits, dim=-1)[0].cpu()
174
+ reason_probs_t = torch.softmax(out.reason_logits, dim=-1)[0].cpu()
175
+
176
+ level_id = int(level_probs_t.argmax())
177
+ level_label = ID_TO_LEVEL[level_id]
178
+ level_probs = {ID_TO_LEVEL[i]: float(level_probs_t[i]) for i in range(5)}
179
+ diff_prob = difficult_class_probability(level_probs_t)
180
+
181
+ reason_label = None
182
+ reason_id = None
183
+ reason_probs = None
184
+ if level_label in HARD_LEVELS:
185
+ reason_id = int(reason_probs_t.argmax())
186
+ reason_label = ID_TO_REASON[reason_id]
187
+ reason_probs = {ID_TO_REASON[i]: float(reason_probs_t[i]) for i in range(3)}
188
+
189
+ return PredictionResult(
190
+ complexity_level=level_label,
191
+ level_id=level_id,
192
+ level_probs=level_probs,
193
+ difficult_class_prob=diff_prob,
194
+ reason=reason_label,
195
+ reason_id=reason_id,
196
+ reason_probs=reason_probs,
197
+ )
198
+
199
+ def predict_batch(self, items: list[dict[str, str]]) -> list[PredictionResult]:
200
+ return [self.predict(item["sentence"], item["target_word"], item.get("corpus")) for item in items]
src/reason_interventions.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sentence edits for reason faithfulness tests (UI + 07_faithfulness.py).
3
+
4
+ Each reason type has one targeted edit; re-predict after edit to see if hardness drops.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+ from syntax_complexity import find_hardest_span
13
+ from utils import REASON_ORDER
14
+
15
+
16
+ @dataclass
17
+ class InterventionResult:
18
+ reason: str
19
+ original_sentence: str
20
+ edited_sentence: str
21
+ edit_method: str # "openai", "gemini", or "rule"
22
+
23
+
24
+ def _call_llm(prompt: str) -> tuple[str | None, str]:
25
+ if os.environ.get("OPENAI_API_KEY"):
26
+ try:
27
+ from openai import OpenAI
28
+
29
+ client = OpenAI()
30
+ resp = client.chat.completions.create(
31
+ model=os.environ.get("FAITHFULNESS_LLM", "gpt-4o-mini"),
32
+ messages=[{"role": "user", "content": prompt}],
33
+ temperature=0,
34
+ max_tokens=256,
35
+ )
36
+ return resp.choices[0].message.content.strip(), "openai"
37
+ except Exception:
38
+ pass
39
+ if os.environ.get("GOOGLE_API_KEY"):
40
+ try:
41
+ import google.generativeai as genai
42
+
43
+ genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
44
+ model = genai.GenerativeModel(os.environ.get("FAITHFULNESS_GEMINI", "gemini-1.5-flash"))
45
+ return model.generate_content(prompt).text.strip(), "gemini"
46
+ except Exception:
47
+ pass
48
+ return None, "rule"
49
+
50
+
51
+ def _rule_synonym_swap(sentence: str, target_word: str) -> str:
52
+ simple = {
53
+ "frankincense": "incense",
54
+ "astonishment": "surprise",
55
+ "partiality": "bias",
56
+ "dominion": "rule",
57
+ "assemblies": "meetings",
58
+ "ewe": "sheep",
59
+ "scribe": "writer",
60
+ "inflammation": "swelling",
61
+ "treaty": "deal",
62
+ }
63
+ rep = simple.get(target_word.lower(), target_word)
64
+ if target_word in sentence:
65
+ return sentence.replace(target_word, rep, 1)
66
+ return sentence
67
+
68
+
69
+ def edit_lexical_rarity(sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
70
+ prompt = (
71
+ f"Replace ONLY the word '{target_word}' in this sentence with a common, simple synonym "
72
+ f"of the same meaning and part of speech. Change nothing else.\n"
73
+ f"Sentence: {sentence}\n"
74
+ f"Output only the edited sentence."
75
+ )
76
+ method = "rule"
77
+ edited = _rule_synonym_swap(sentence, target_word)
78
+ if use_llm:
79
+ llm_out, method = _call_llm(prompt)
80
+ if llm_out:
81
+ edited = llm_out
82
+ return InterventionResult("Lexical Rarity", sentence, edited, method)
83
+
84
+
85
+ def edit_contextual_ambiguity(sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
86
+ prompt = (
87
+ f"Add at most 3 words to this sentence to make the meaning of '{target_word}' clear. "
88
+ f"Do NOT change or remove '{target_word}'.\n"
89
+ f"Sentence: {sentence}\n"
90
+ f"Output only the edited sentence."
91
+ )
92
+ method = "rule"
93
+ edited = f"{sentence} (meaning: {target_word})"
94
+ if use_llm:
95
+ llm_out, method = _call_llm(prompt)
96
+ if llm_out:
97
+ edited = llm_out
98
+ return InterventionResult("Contextual Ambiguity", sentence, edited, method)
99
+
100
+
101
+ def edit_syntactic_complexity(sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
102
+ span, start, end = find_hardest_span(sentence)
103
+ method = "rule"
104
+ simplified = span
105
+ if use_llm and span.strip() != sentence.strip():
106
+ prompt = (
107
+ f"Simplify ONLY this phrase to plain English. Keep all key words including '{target_word}'.\n"
108
+ f"Phrase: {span}\n"
109
+ f"Output only the simplified phrase."
110
+ )
111
+ llm_out, method = _call_llm(prompt)
112
+ if llm_out:
113
+ simplified = llm_out
114
+ edited = sentence[:start] + simplified + sentence[end:]
115
+ return InterventionResult("Syntactic Complexity", sentence, edited, method)
116
+
117
+
118
+ EDIT_FNS = {
119
+ "Lexical Rarity": edit_lexical_rarity,
120
+ "Contextual Ambiguity": edit_contextual_ambiguity,
121
+ "Syntactic Complexity": edit_syntactic_complexity,
122
+ }
123
+
124
+
125
+ def apply_intervention(reason: str, sentence: str, target_word: str, use_llm: bool = True) -> InterventionResult:
126
+ if reason not in EDIT_FNS:
127
+ raise ValueError(f"Unknown reason: {reason}. Choose from {REASON_ORDER}")
128
+ return EDIT_FNS[reason](sentence, target_word, use_llm=use_llm)
129
+
130
+
131
+ def apply_all_interventions(sentence: str, target_word: str, use_llm: bool = True) -> list[InterventionResult]:
132
+ return [fn(sentence, target_word, use_llm=use_llm) for fn in EDIT_FNS.values()]
src/syntax_complexity.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Structural syntax analysis for word complexity project.
3
+
4
+ Provides:
5
+ - analyze_syntax(sentence): structural metrics for a sentence
6
+ - find_hardest_span(sentence): the most syntactically tangled span (for faithfulness edits)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+ from typing import Any
15
+
16
+ import spacy
17
+ from spacy.tokens import Doc, Span, Token
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def _load_nlp():
22
+ try:
23
+ return spacy.load("en_core_web_sm")
24
+ except OSError as exc:
25
+ raise RuntimeError(
26
+ "spaCy model 'en_core_web_sm' not found. Run: python -m spacy download en_core_web_sm"
27
+ ) from exc
28
+
29
+
30
+ @dataclass
31
+ class SyntaxMetrics:
32
+ token_count: int
33
+ max_tree_depth: int
34
+ avg_dependency_length: float
35
+ subordinate_clause_count: int
36
+ passive_voice_count: int
37
+ punctuation_density: float
38
+ complexity_score: float
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "token_count": self.token_count,
43
+ "max_tree_depth": self.max_tree_depth,
44
+ "avg_dependency_length": self.avg_dependency_length,
45
+ "subordinate_clause_count": self.subordinate_clause_count,
46
+ "passive_voice_count": self.passive_voice_count,
47
+ "punctuation_density": self.punctuation_density,
48
+ "complexity_score": self.complexity_score,
49
+ }
50
+
51
+
52
+ def _token_depth(token: Token) -> int:
53
+ depth = 0
54
+ while token.head != token:
55
+ depth += 1
56
+ token = token.head
57
+ return depth
58
+
59
+
60
+ def _subtree_depth(token: Token) -> int:
61
+ children = list(token.subtree)
62
+ if not children:
63
+ return 0
64
+ return 1 + max(_subtree_depth(child) for child in token.children) if token.children else 1
65
+
66
+
67
+ def _dependency_length(token: Token) -> int:
68
+ if token.head == token:
69
+ return 0
70
+ return abs(token.i - token.head.i)
71
+
72
+
73
+ def _count_subordinate_clauses(doc: Doc) -> int:
74
+ markers = {"ccomp", "xcomp", "advcl", "relcl", "acl"}
75
+ return sum(1 for tok in doc if tok.dep_ in markers)
76
+
77
+
78
+ def _count_passive(doc: Doc) -> int:
79
+ return sum(
80
+ 1
81
+ for tok in doc
82
+ if tok.dep_ in {"nsubjpass", "auxpass"} or (tok.tag_ == "VBN" and tok.dep_ == "ROOT")
83
+ )
84
+
85
+
86
+ def analyze_syntax(sentence: str) -> SyntaxMetrics:
87
+ """Return structural complexity metrics for a sentence."""
88
+ nlp = _load_nlp()
89
+ doc = nlp(sentence or "")
90
+ tokens = [t for t in doc if not t.is_space]
91
+ if not tokens:
92
+ return SyntaxMetrics(0, 0, 0.0, 0, 0, 0.0, 0.0)
93
+
94
+ depths = [_token_depth(t) for t in tokens]
95
+ dep_lengths = [_dependency_length(t) for t in tokens]
96
+ max_depth = max(depths)
97
+ avg_dep = sum(dep_lengths) / len(dep_lengths)
98
+ sub_clauses = _count_subordinate_clauses(doc)
99
+ passive = _count_passive(doc)
100
+ punct = len(re.findall(r"[,;:()\[\]{}\"']", sentence))
101
+ punct_density = punct / max(len(sentence), 1)
102
+
103
+ complexity_score = (
104
+ 0.35 * max_depth
105
+ + 0.25 * avg_dep
106
+ + 0.20 * sub_clauses
107
+ + 0.10 * passive
108
+ + 0.10 * punct_density * 20
109
+ + 0.05 * (len(tokens) / 30.0)
110
+ )
111
+
112
+ return SyntaxMetrics(
113
+ token_count=len(tokens),
114
+ max_tree_depth=max_depth,
115
+ avg_dependency_length=avg_dep,
116
+ subordinate_clause_count=sub_clauses,
117
+ passive_voice_count=passive,
118
+ punctuation_density=punct_density,
119
+ complexity_score=complexity_score,
120
+ )
121
+
122
+
123
+ def _span_complexity(span: Span) -> float:
124
+ text = span.text.strip()
125
+ if not text:
126
+ return 0.0
127
+ metrics = analyze_syntax(text)
128
+ return metrics.complexity_score
129
+
130
+
131
+ def find_hardest_span(sentence: str, min_tokens: int = 3) -> tuple[str, int, int]:
132
+ """
133
+ Find the most syntactically tangled contiguous span in the sentence.
134
+
135
+ Returns (span_text, start_char, end_char).
136
+ """
137
+ nlp = _load_nlp()
138
+ doc = nlp(sentence or "")
139
+ tokens = [t for t in doc if not t.is_space]
140
+ if len(tokens) < min_tokens:
141
+ return sentence, 0, len(sentence)
142
+
143
+ best_score = -1.0
144
+ best_span: Span | None = None
145
+
146
+ for start in range(len(tokens)):
147
+ for end in range(start + min_tokens, min(start + 25, len(tokens)) + 1):
148
+ span = doc[tokens[start].i : tokens[end - 1].i + 1]
149
+ score = _span_complexity(span)
150
+ if score > best_score:
151
+ best_score = score
152
+ best_span = span
153
+
154
+ if best_span is None:
155
+ return sentence, 0, len(sentence)
156
+
157
+ return best_span.text, best_span.start_char, best_span.end_char
158
+
159
+
160
+ def syntax_signal_high(sentence: str, percentile_threshold: float = 0.6) -> bool:
161
+ """Heuristic: sentence is structurally complex relative to typical text."""
162
+ score = analyze_syntax(sentence).complexity_score
163
+ return score >= percentile_threshold * 8.0
src/two_head_model.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage E — Two-head model for 5-class word complexity + 3-class reason.
3
+
4
+ No regression head. Outputs are discrete levels (Very Easy → Very Hard) and,
5
+ when Hard/Very Hard, one of three explainable difficulty causes.
6
+
7
+ Novel architecture (publish contribution): one encoder, dual heads, masked
8
+ reason loss, target-span pooling for word-in-context LCP.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+ from transformers import AutoModel, AutoTokenizer, PreTrainedTokenizer
20
+
21
+ from linguistic_features import N_LINGUISTIC_FEATURES
22
+ from utils import (
23
+ MODELS,
24
+ POOLING_CLS_CONCAT,
25
+ POOLING_CLS_ONLY,
26
+ POOLING_SPAN,
27
+ POOLING_TGT_MARKER,
28
+ TGT_END_TOKEN,
29
+ TGT_TOKEN,
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class ModelOutput:
35
+ level_logits: torch.Tensor
36
+ reason_logits: torch.Tensor
37
+ pooled: torch.Tensor
38
+
39
+
40
+ class TwoHeadModel(nn.Module):
41
+ """Shared encoder with level (5-class) and reason (3-class) heads."""
42
+
43
+ def __init__(
44
+ self,
45
+ model_name: str = MODELS["deberta"],
46
+ hidden_dropout: float = 0.1,
47
+ pooling_mode: str = POOLING_SPAN,
48
+ use_linguistic_features: bool = False,
49
+ # Legacy alias: cls_concat maps to cls_concat pooling
50
+ use_cls_concat: bool | None = None,
51
+ ):
52
+ super().__init__()
53
+ if use_cls_concat is not None:
54
+ pooling_mode = POOLING_CLS_CONCAT if use_cls_concat else POOLING_SPAN
55
+
56
+ self.encoder = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32)
57
+ hidden = self.encoder.config.hidden_size
58
+ self.pooling_mode = pooling_mode
59
+ self.use_linguistic_features = use_linguistic_features
60
+ self.model_name = model_name
61
+
62
+ extra = N_LINGUISTIC_FEATURES if use_linguistic_features else 0
63
+ if pooling_mode == POOLING_CLS_CONCAT:
64
+ head_in = hidden * 2 + extra
65
+ else:
66
+ head_in = hidden + extra
67
+
68
+ self.dropout = nn.Dropout(hidden_dropout)
69
+ self.level_head = nn.Linear(head_in, 5)
70
+ self.reason_head = nn.Linear(head_in, 3)
71
+
72
+ self._tgt_id: Optional[int] = None
73
+ self._tgt_end_id: Optional[int] = None
74
+
75
+ def set_tgt_token_ids(self, tgt_id: int, tgt_end_id: int | None = None) -> None:
76
+ self._tgt_id = tgt_id
77
+ self._tgt_end_id = tgt_end_id
78
+
79
+ def set_tgt_token_id(self, tgt_id: int) -> None:
80
+ self.set_tgt_token_ids(tgt_id, self._tgt_end_id)
81
+
82
+ def _span_pool(self, last_hidden: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor:
83
+ """Mean-pool token hidden states inside [TGT] ... [/TGT] (target word subwords)."""
84
+ batch_size = input_ids.size(0)
85
+ pooled = []
86
+ cls_vec = last_hidden[:, 0, :]
87
+
88
+ for i in range(batch_size):
89
+ ids = input_ids[i]
90
+ open_pos = (ids == self._tgt_id).nonzero(as_tuple=True)[0] if self._tgt_id is not None else None
91
+ if open_pos is None or len(open_pos) == 0:
92
+ pooled.append(cls_vec[i])
93
+ continue
94
+
95
+ start = int(open_pos[0].item()) + 1
96
+ end = len(ids)
97
+ if self._tgt_end_id is not None:
98
+ close_pos = (ids == self._tgt_end_id).nonzero(as_tuple=True)[0]
99
+ close_after = close_pos[close_pos > open_pos[0]]
100
+ if len(close_after) > 0:
101
+ end = int(close_after[0].item())
102
+
103
+ span_idx = [j for j in range(start, end) if ids[j].item() != 0] # skip pad
104
+ if not span_idx:
105
+ pooled.append(last_hidden[i, int(open_pos[0].item()), :])
106
+ else:
107
+ vecs = last_hidden[i, span_idx, :]
108
+ pooled.append(vecs.mean(dim=0))
109
+
110
+ return torch.stack(pooled, dim=0)
111
+
112
+ def _first_marker_pool(self, last_hidden: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor:
113
+ cls_vec = last_hidden[:, 0, :]
114
+ if self._tgt_id is None:
115
+ return cls_vec
116
+
117
+ batch_size = input_ids.size(0)
118
+ vecs = []
119
+ for i in range(batch_size):
120
+ positions = (input_ids[i] == self._tgt_id).nonzero(as_tuple=True)[0]
121
+ if len(positions) > 0:
122
+ vecs.append(last_hidden[i, positions[0], :])
123
+ else:
124
+ vecs.append(cls_vec[i])
125
+ return torch.stack(vecs, dim=0)
126
+
127
+ def _pool(self, last_hidden: torch.Tensor, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
128
+ cls_vec = last_hidden[:, 0, :]
129
+
130
+ if self.pooling_mode == POOLING_CLS_ONLY:
131
+ return cls_vec
132
+ if self.pooling_mode == POOLING_SPAN:
133
+ return self._span_pool(last_hidden, input_ids)
134
+ if self.pooling_mode == POOLING_TGT_MARKER:
135
+ return self._first_marker_pool(last_hidden, input_ids)
136
+ if self.pooling_mode == POOLING_CLS_CONCAT:
137
+ tgt_vec = self._first_marker_pool(last_hidden, input_ids)
138
+ return torch.cat([cls_vec, tgt_vec], dim=-1)
139
+
140
+ return self._span_pool(last_hidden, input_ids)
141
+
142
+ def forward(
143
+ self,
144
+ input_ids: torch.Tensor,
145
+ attention_mask: torch.Tensor,
146
+ linguistic_features: Optional[torch.Tensor] = None,
147
+ ) -> ModelOutput:
148
+ outputs = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
149
+ pooled = self._pool(outputs.last_hidden_state, input_ids, attention_mask)
150
+
151
+ if self.use_linguistic_features:
152
+ if linguistic_features is None:
153
+ raise ValueError("linguistic_features required when use_linguistic_features=True")
154
+ pooled = torch.cat([pooled, linguistic_features], dim=-1)
155
+
156
+ pooled = self.dropout(pooled)
157
+ # Encoder may run in float16 on GPU; classification heads stay float32.
158
+ pooled = pooled.to(dtype=self.level_head.weight.dtype)
159
+
160
+ return ModelOutput(
161
+ level_logits=self.level_head(pooled),
162
+ reason_logits=self.reason_head(pooled),
163
+ pooled=pooled,
164
+ )
165
+
166
+
167
+ def add_tgt_tokens(tokenizer: PreTrainedTokenizer) -> tuple[int, int]:
168
+ """Add [TGT] and [/TGT] special tokens; return their ids."""
169
+ special = {"additional_special_tokens": [TGT_TOKEN, TGT_END_TOKEN]}
170
+ tokenizer.add_special_tokens(special)
171
+ return (
172
+ tokenizer.convert_tokens_to_ids(TGT_TOKEN),
173
+ tokenizer.convert_tokens_to_ids(TGT_END_TOKEN),
174
+ )
175
+
176
+
177
+ def add_tgt_token(tokenizer: PreTrainedTokenizer) -> int:
178
+ """Backward-compatible: add markers and return open [TGT] id."""
179
+ open_id, _ = add_tgt_tokens(tokenizer)
180
+ return open_id
181
+
182
+
183
+ def compute_loss(
184
+ level_logits: torch.Tensor,
185
+ reason_logits: torch.Tensor,
186
+ level_ids: torch.Tensor,
187
+ reason_ids: torch.Tensor,
188
+ reason_mask: torch.Tensor,
189
+ reason_class_weights: Optional[torch.Tensor] = None,
190
+ lambda_reason: float = 1.0,
191
+ level_only: bool = False,
192
+ ) -> tuple[torch.Tensor, dict]:
193
+ level_loss = F.cross_entropy(level_logits, level_ids)
194
+
195
+ if level_only:
196
+ return level_loss, {"level_loss": level_loss.item(), "reason_loss": 0.0, "total_loss": level_loss.item()}
197
+
198
+ per_row = F.cross_entropy(
199
+ reason_logits,
200
+ reason_ids,
201
+ weight=reason_class_weights,
202
+ reduction="none",
203
+ )
204
+ masked = per_row * reason_mask
205
+ denom = reason_mask.sum().clamp(min=1.0)
206
+ reason_loss = masked.sum() / denom
207
+ total = level_loss + lambda_reason * reason_loss
208
+
209
+ return total, {
210
+ "level_loss": level_loss.item(),
211
+ "reason_loss": reason_loss.item(),
212
+ "total_loss": total.item(),
213
+ }
214
+
215
+
216
+ def load_tokenizer(model_key: str = "deberta") -> PreTrainedTokenizer:
217
+ return AutoTokenizer.from_pretrained(MODELS[model_key])
218
+
219
+
220
+ def build_model(
221
+ model_key: str = "deberta",
222
+ pooling_mode: str = POOLING_SPAN,
223
+ use_linguistic_features: bool = False,
224
+ use_cls_concat: bool | None = None,
225
+ ) -> TwoHeadModel:
226
+ return TwoHeadModel(
227
+ model_name=MODELS[model_key],
228
+ pooling_mode=pooling_mode,
229
+ use_linguistic_features=use_linguistic_features,
230
+ use_cls_concat=use_cls_concat,
231
+ )
src/ui/__init__.py ADDED
File without changes
src/ui/efficiency_panel.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Compare local model vs AI API: latency and estimated cost per query.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ LEVEL_PROMPT = """Rate the complexity of the target word in context.
10
+ Levels: Very Easy, Easy, Medium, Hard, Very Hard.
11
+ Sentence: {sentence}
12
+ Target word: {target_word}
13
+ Reply with only the level label."""
14
+
15
+ # USD per 1k queries (rough; matches 08_efficiency.py defaults)
16
+ COST_PER_1K = {
17
+ "openai_gpt4o_mini": 0.15,
18
+ "gemini_flash": 0.075,
19
+ }
20
+
21
+ # Typical latency when API is used (ms) — used when no live API call
22
+ TYPICAL_AI_LATENCY_MS = {
23
+ "openai_gpt4o_mini": 1200.0,
24
+ "gemini_flash": 900.0,
25
+ }
26
+
27
+
28
+ @dataclass
29
+ class QueryComparison:
30
+ local_latency_ms: float
31
+ ai_latency_ms: float
32
+ ai_provider: str
33
+ ai_cost_usd: float
34
+ ai_cost_per_1k_usd: float
35
+ local_cost_usd: float
36
+ speedup_factor: float
37
+ prompt_tokens_est: int
38
+
39
+
40
+ def _estimate_tokens(text: str) -> int:
41
+ return max(1, len(text.split()) + len(text) // 4)
42
+
43
+
44
+ def estimate_ai_cost(sentence: str, target_word: str, provider: str = "openai_gpt4o_mini") -> float:
45
+ prompt = LEVEL_PROMPT.format(sentence=sentence, target_word=target_word)
46
+ tokens = _estimate_tokens(prompt) + 10 # short reply
47
+ # gpt-4o-mini ballpark: ~$0.15/1M input + $0.60/1M output — ~$0.0002 per simple call
48
+ cost_per_1k = COST_PER_1K.get(provider, 0.15)
49
+ return (cost_per_1k / 1000.0) * (tokens / 200.0)
50
+
51
+
52
+ def compare_query(sentence: str, target_word: str, local_latency_ms: float) -> QueryComparison:
53
+ provider = "openai_gpt4o_mini"
54
+ prompt = LEVEL_PROMPT.format(sentence=sentence, target_word=target_word)
55
+ tokens = _estimate_tokens(prompt)
56
+ ai_cost = estimate_ai_cost(sentence, target_word, provider)
57
+ ai_latency = TYPICAL_AI_LATENCY_MS[provider]
58
+ local_cost = 0.0
59
+ speedup = ai_latency / max(local_latency_ms, 0.1)
60
+ return QueryComparison(
61
+ local_latency_ms=local_latency_ms,
62
+ ai_latency_ms=ai_latency,
63
+ ai_provider="GPT-4o-mini (estimated)",
64
+ ai_cost_usd=ai_cost,
65
+ ai_cost_per_1k_usd=COST_PER_1K[provider],
66
+ local_cost_usd=local_cost,
67
+ speedup_factor=speedup,
68
+ prompt_tokens_est=tokens,
69
+ )
src/utils.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared constants and helpers for the word complexity project."""
2
+
3
+ from pathlib import Path
4
+
5
+ PROJECT_ROOT = Path(__file__).resolve().parent
6
+ DATA_DIR = PROJECT_ROOT / "data"
7
+ OUTPUT_DIR = PROJECT_ROOT / "outputs"
8
+ CHECKPOINT_DIR = PROJECT_ROOT / "checkpoints"
9
+ EXPORT_DIR = PROJECT_ROOT / "exported_models"
10
+
11
+ LEVEL_ORDER = ["Very Easy", "Easy", "Medium", "Hard", "Very Hard"]
12
+ REASON_ORDER = ["Lexical Rarity", "Contextual Ambiguity", "Syntactic Complexity"]
13
+ HARD_LEVELS = {"Hard", "Very Hard"}
14
+
15
+ LEVEL_TO_ID = {level: idx for idx, level in enumerate(LEVEL_ORDER)}
16
+ ID_TO_LEVEL = {idx: level for level, idx in LEVEL_TO_ID.items()}
17
+ REASON_TO_ID = {reason: idx for idx, reason in enumerate(REASON_ORDER)}
18
+ ID_TO_REASON = {idx: reason for reason, idx in REASON_TO_ID.items()}
19
+ NONE_REASON_ID = -1
20
+
21
+ TGT_TOKEN = "[TGT]"
22
+ TGT_END_TOKEN = "[/TGT]"
23
+
24
+ # Input encoding strategies (SemEval / ABSA literature)
25
+ ENCODING_SPAN_MARK = "span_mark" # [TGT] word [/TGT] inside sentence
26
+ ENCODING_PAIR_CAMBRIDGE = "pair_cambridge" # [CLS] target [SEP] sentence
27
+ ENCODING_PAIR_CONTEXT = "pair_context" # sentence [SEP] target (SemEval common)
28
+
29
+ # Pooling strategies for target-focused readout
30
+ POOLING_SPAN = "span" # mean of tokens inside marked span (default)
31
+ POOLING_CLS_CONCAT = "cls_concat" # legacy: [CLS] + first [TGT]
32
+ POOLING_TGT_MARKER = "tgt_marker" # first [TGT] token only
33
+ POOLING_CLS_ONLY = "cls_only" # [CLS] only (for pair encoding)
34
+
35
+ MODELS = {
36
+ "deberta": "microsoft/deberta-v3-base",
37
+ "distilbert": "distilbert-base-uncased",
38
+ "roberta": "roberta-base",
39
+ }
40
+
41
+ MERGE_KEYS = ["id", "sentence", "target_word", "complexity_level"]
42
+
43
+
44
+ def level_id(level: str) -> int:
45
+ return LEVEL_TO_ID[level]
46
+
47
+
48
+ def reason_id(reason: str) -> int:
49
+ if reason in ("NONE", None) or (isinstance(reason, float) and str(reason) == "nan"):
50
+ return NONE_REASON_ID
51
+ return REASON_TO_ID[reason]
52
+
53
+
54
+ def is_hard_level(level: str) -> bool:
55
+ return level in HARD_LEVELS
56
+
57
+
58
+ def wrap_target_word(sentence: str, target_word: str) -> str:
59
+ """Wrap target word with open/close span markers (ABSA aspect-marker style)."""
60
+ if not target_word or target_word not in sentence:
61
+ return sentence
62
+ marked = f"{TGT_TOKEN} {target_word} {TGT_END_TOKEN}"
63
+ return sentence.replace(target_word, marked, 1)
64
+
65
+
66
+ def build_model_input(
67
+ sentence: str,
68
+ target_word: str,
69
+ encoding: str = ENCODING_SPAN_MARK,
70
+ corpus: str | None = None,
71
+ ) -> str | tuple[str, str]:
72
+ """
73
+ Build tokenizer input for LCP.
74
+
75
+ Returns a string for span marking, or (text_a, text_b) for pair encodings.
76
+ """
77
+ sentence = str(sentence)
78
+ target_word = str(target_word)
79
+
80
+ if encoding == ENCODING_SPAN_MARK:
81
+ return wrap_target_word(sentence, target_word)
82
+
83
+ if encoding == ENCODING_PAIR_CAMBRIDGE:
84
+ return target_word, sentence
85
+
86
+ if encoding == ENCODING_PAIR_CONTEXT:
87
+ if corpus:
88
+ return f"{corpus} {target_word}", sentence
89
+ return sentence, target_word
90
+
91
+ raise ValueError(f"Unknown encoding: {encoding}")
92
+
93
+
94
+ def difficult_class_probability(level_probs) -> float:
95
+ """P(Hard) + P(Very Hard) from the 5-class softmax — auxiliary only, not a regression score."""
96
+ if hasattr(level_probs, "tolist"):
97
+ level_probs = level_probs.tolist()
98
+ if isinstance(level_probs, dict):
99
+ return float(level_probs.get("Hard", 0) + level_probs.get("Very Hard", 0))
100
+ return float(level_probs[3] + level_probs[4])
101
+
102
+
103
+ def ensure_dirs() -> None:
104
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
105
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
106
+ CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
107
+ EXPORT_DIR.mkdir(parents=True, exist_ok=True)
108
+
109
+
110
+ def tokenize_lcp_input(
111
+ tokenizer,
112
+ sentence: str,
113
+ target_word: str,
114
+ encoding: str = ENCODING_SPAN_MARK,
115
+ max_length: int = 192,
116
+ corpus: str | None = None,
117
+ ):
118
+ """Tokenize a (sentence, target_word) pair for the LCP model."""
119
+ built = build_model_input(sentence, target_word, encoding=encoding, corpus=corpus)
120
+ if isinstance(built, tuple):
121
+ return tokenizer(
122
+ built[0],
123
+ built[1],
124
+ truncation=True,
125
+ max_length=max_length,
126
+ padding="max_length",
127
+ return_tensors="pt",
128
+ )
129
+ return tokenizer(
130
+ built,
131
+ truncation=True,
132
+ max_length=max_length,
133
+ padding="max_length",
134
+ return_tensors="pt",
135
+ )