Built by Srinjay Panja
Live in production: every browsed URL gets a verdict in single-digit
milliseconds (Tier-1 p50 0.45 ms in-process, ~7 ms over HTTP), suspicious
pages get a full headless-browser render plus an LLM judgment, and the whole
thing runs at $0/month on Cloud Run's free tier. Load the unpacked
extension/ in Chrome, point it at the live backend above — no local setup.
PhishNet is a phishing-URL detector with a browser extension, a FastAPI serving container, and — more importantly — an evaluation pipeline that records what works, what does not, and what it refuses to claim.
The champion is a single LightGBM over lexical URL features plus one hosting
signal (is_hosted_tenant), trained on a temporal, domain-disjoint split. It
does not use WHOIS/age at serving (ineligible: it failed its own
contamination gate), and it is not retrained between phases — every number
below is attached to the same frozen weights (ablation_lexical_gbm_model.pkl,
SHA256-pinned in src/phishnet/model_manifest.json).
Serving is a two-stage cascade: Tier 1 scores every URL string; rows in the
band (0.6493 ≤ score < 0.9269) go to Tier 2 (a hardened LLM page judgment
plus a frozen injection detector), and any Tier-2 failure alerts (fail
closed).
| what | number | note |
|---|---|---|
Recall @ 0.5% FPR (t_alert = 0.9269) |
50.4% (achieved FPR 0.40%) | indistinguishable from the budget |
Recall @ 1.0% FPR (t_1pct = 0.8781) |
60.7% (achieved FPR 0.98%) | indistinguishable |
| Cold start, row (b), age forced unknown | 53.4% | losing age costs ~25pp vs the age-known mix |
| Tier-1 serving latency p50 | 0.45 ms in-process / ~7 ms HTTP | criterion 12 met (was 14.3 ms) |
| Phase 4 LLM layer | unanswered (bounded) | structural ceiling +0.0347 recall at most |
| Webflow-class hosted phishing | missed | recorded, retrain work (docs/production-gaps.md §8) |
Swept-on-test numbers appear only where labeled unattainable. The protocol is
docs/phase3-preregistration.md; results are reports/phase3.md,
reports/phase5-adversarial.md, and reports/phase6.md; the model card is
docs/model-card.md.
Live production backend (no local setup needed):
https://phishnet-serving-mz5maa3blq-uc.a.run.app
Load the extension (chrome://extensions → Developer mode → Load
unpacked → extension/), open its Options page, paste the URL above as
the Backend Base URL, and Save & Test Connection. Cloud Run free tier,
live Tier 2 with the mechanism failure policy (verified end-to-end:
Tier-1 bit-equal, Tier-2 LLM verdicts).
Or install from Microsoft Edge Add-ons (same extension, no manual load):
No git clone needed: grab phishnet-extension-v2.0.zip from
Releases, unzip it, and
Load unpacked the folder instead — same steps after that. Verify with
SHA-256 f605cab73d07f2b625e4be472bd81504fae520dd7ecafbdbaa99975129f3f8b4.
To run locally instead, start a serving container and point the extension
at http://localhost:8000:
- Start the container (sealed demo mode — offline, replays the registered
Phase 5 verdicts):
docker build -f backend/Dockerfile -t phishnet-serving . docker run --rm -p 8000:8000 phishnet-serving - Point the extension at
http://localhost:8000in its Options page (Save & Test Connection). - Browse. The notification shows the disposition (
alert/allow/can't assess), the Tier-1 score, and the top SHAP features.allowmeans "no alert at the calibrated operating point" — not a safety guarantee.
For live Tier-2 (real page fetch + LLM judgment) instead of the sealed replay, run the two-layer stack:
cp backend/.env.example .env # put GROQ_API_KEY in .env
docker compose up --buildOut-of-band rows never call Tier 2, so ordinary browsing costs nothing beyond
the local score. PHISHNET_TIER2_MODE=live is fail-loud: if the key or
fetcher URL is missing, the container refuses to start rather than silently
serving "can't assess" for every in-band URL.
- Real-time URL analysis via the browser extension, with native LightGBM SHAP explanations.
- Two-stage cascade: a calibrated lexical champion, then an LLM page judgment with a frozen injection detector for in-band rows only.
- Fail-closed Tier-2: a schema/refusal/timeout/unfetchable outcome alerts rather than silently retaining a score.
- Shortener resolution: follows redirects and scores the final URL; unresolved → "can't assess", never a verdict score.
- Reproducible: SHA256-pinned weights, frozen thresholds, sealed run stores, and a golden dataset-identity test suite.
Machine learning
| Tool | Role |
|---|---|
| LightGBM 4.6 | The served champion (Phase 3 row (a)): one gradient-boosted tree model over 79 URL features; native tree-SHAP via pred_contrib |
| scikit-learn 1.6 | Legacy 4-model hard-vote ensemble (Random Forest, Logistic Regression, Decision Tree, Gradient Boosting), calibration (isotonic/sigmoid), metrics |
| pandas / NumPy | Feature tables, splits, report rendering |
| tldextract 5.1 | Registrable-domain parsing on a pinned public-suffix snapshot (no network at runtime) |
| python-Levenshtein | Brand-distance features (*_min_distance) |
| pyarrow | Parquet reader for the Common Crawl wave fetch |
Serving and API
| Tool | Role |
|---|---|
| FastAPI + Uvicorn | GET /health, POST /predict, POST /explain |
| Pydantic v2 | Request/response validation (and URL normalization) |
| Docker / Docker Compose | Slim Tier-1 image + a separate Playwright fetcher image, so latency is not measured beside a browser |
| LightGBM native SHAP | Per-feature contributions in the /explain response |
LLM layer (Tier 2, in-band rows only)
| Tool | Role |
|---|---|
Groq API — openai/gpt-oss-120b |
Strict JSON-schema page judgment (temperature 0, seed 0, reasoning_effort low) |
| Playwright (Chromium) | Renders the page in the fetcher image (backend/fetcher/Dockerfile) |
| BeautifulSoup 4 | HTML → frozen canonical extract (title, visible text, form fields, link hosts) |
Data pipeline
| Tool | Role |
|---|---|
PhishTank online-valid + OpenPhish |
Phishing feeds (temporal labels; takedown-filtered before collection) |
| Tranco 46VQX 1M | Benign seed domains (pinned, citable list ID) |
Common Crawl CC-MAIN-2026-34 (fallback -30) |
Benign deep-link corpus |
| AWS Athena + AWS Glue Data Catalog + Amazon S3 | Per-domain queries against the Common Crawl columnar index at s3://commoncrawl/cc-index/table/cc-main/warc/; the ccindex database/table/partitions live in the Glue Data Catalog; results are written to an S3 bucket |
| boto3 | Athena client (fetch-only, imported lazily so the locked runtime stays minimal) |
| RDAP / WHOIS | Domain-age enrichment (point-in-time; ineligible for the headline) |
| crt.sh | Certificate-transparency history (dropped unmeasured, Amendment E) |
Tooling and CI
| Tool | Role |
|---|---|
| uv + hatchling | Locked dependency resolution and packaging |
| ruff + mypy | Lint/format and strict typing |
| pytest | 570 tests, including golden dataset-identity and serving-identity gates |
| GitHub Actions | ci, repro, eval, collect, phase4-forward workflows |
The Athena table is defined in the AWS Glue Data Catalog (Athena's catalog); the IAM policy at
docs/aws-athena-iam-policy.jsongrants the Glue catalog actions plus S3 read oncommoncrawland write to a results bucket. No Glue crawler or ETL job is run — only the catalog Athena requires.
extension ──POST /predict──► phishnet.serving (FastAPI)
│
├─ shortener? resolve → score final URL
▼
Tier 1 (LightGBM fast path) p50 0.45 ms
│
┌──────────┼───────────┐
< 0.6493 in band ≥ 0.9269
allow │ alert
▼
Tier 2 (LLM + detector, fail closed)
sealed (offline) │ live (Playwright + Groq)
Full diagram with latency/cost annotations: docs/architecture.md.
Vocabulary: row (a) = the served champion (Phase-3 lexical LightGBM, 79
columns); p5-h1 = frozen Phase-5 prompt arm replayed by sealed Tier-2;
p6-v1 = live prompt + widened response schema; R6 = serving detector fix
(line-anchored system-marker); T2-9 = risk-graded fail-closed floor for
Tier-2 failures.
PhishTank / OpenPhish ─┐
├─► data/raw/*.jsonl (append-only daily snapshots)
Tranco 46VQX ──────────┘ │
▼
Common Crawl index ──► AWS Athena (S3) ──► build_cc_benign.py ──► validate_cc_benign.py
(CC-MAIN-2026-34) per-domain query fetch + select hard mechanism gates
│
▼
build_splits.py ──► data/splits-p3/{train,calib,test}.csv
(temporal phish, │ + leakage / shape audit
domain-hash benign) ▼
RDAP/WHOIS enrichment (point-in-time)
│
▼
ml_training/train_ablation.py ──► backend/ablation_lexical_assets/
│
▼
eval.py + predictors.py ──► reports/*.json|md
│
▼
src/phishnet/serving (FastAPI) ──► extension/
| Stage | Entry point | Notes |
|---|---|---|
| Collect | collect.py (collect.yml) |
PhishTank needs an app key; OpenPhish is key-free; Tranco via API key |
| Benign corpus | build_cc_benign.py |
Common Crawl columnar index via Athena (--source columnar, default) or CDX probe; Parquet wave path (fetch_cc_wave.py) |
| Validate | validate_cc_benign.py |
Scheme-gap, path-depth, length-inversion, overlap gates; never trains |
| Split | build_splits.py |
Temporal cutoff for phishing, registrable-domain hash for benign; no per-URL random splitting |
| Enrich | src/phishnet/enrichment/ |
RDAP age + point-in-time join; CT dropped |
| Train | ml_training/train_ablation.py |
Phase 3 row (a)/(b) ablations; train_gbm.py, calibrate_gbm.py for the Phase 2 lineage |
| Evaluate | eval.py, predictors.py |
Fixed report; any .score(urls) predictor |
| Serve | src/phishnet/serving/ |
Tier-1 → Tier-2 cascade, shortener resolution, fail-closed |
| Harden | src/phishnet/adversarial/, scripts/p5_*.py |
Injection detector + adversarial evaluation (Phase 5) |
The Common Crawl benign acquisition has been run end to end — first full fetch + select on 2026-09-15. The Athena columnar query wrote to the S3 results bucket and produced the pinned 12,000-row corpus (
data/raw/benign-cc-CC-MAIN-2026-34-2026-09-15.jsonl), which passed the gate battery and was promoted todata/splits-cc/. Seedocs/cc-benign-acquisition.md.
PhishNet/
├── .github/workflows/ # ci, repro, eval, collect, phase4-forward
├── backend/
│ ├── Dockerfile # Tier-1 serving image (uv-based; build from repo root)
│ └── fetcher/ # Separate Playwright fetcher image (live Tier 2)
├── extension/ # Manifest V3 browser extension
├── ml_training/ # Training, ablation, and calibration scripts
├── docs/ # preregistrations (phases 3-7), model card, audits, runbooks
├── repro/ # hashes.json + verify.py + check_golden.py
├── src/phishnet/ # Canonical packaged app
│ ├── features/extraction.py # Canonical feature extractor
│ ├── serving/ # Tier-1 fast path, cascade, shortener, app
│ ├── fetcher/ # Playwright fetcher service (live Tier 2)
│ ├── llm/ # Prompts + response schemas (p4-v1/p5-h1/p6-v1) + Groq client
│ ├── snapshot/ # Fetch/extract/join, eval-mode Tier-1 reference
│ ├── enrichment/ # RDAP age + point-in-time join (CT dropped)
│ ├── adversarial/ # Phase 5 injection detector + lexical transforms
│ ├── verified_download.py # SHA256-verified model-artifact downloader
│ └── model_manifest.json # Artifact names, URLs, and hashes
├── tests/ # pytest suite (serving identity, cascade, schema, dataset identity, ...)
├── collect.py # Append-only daily feed snapshot -> data/raw/
├── build_splits.py # Temporal + domain-disjoint splits -> data/splits/
├── build_cc_benign.py # Common Crawl benign corpus (Athena columnar / CDX probe)
├── validate_cc_benign.py # Corpus + trial-split gates
├── eval.py # Harness: any .score(urls) predictor in, fixed report out
├── predictors.py # Predictor adapters + canaries + native SHAP
├── Makefile # report/collect/split/baseline/eval/canary/test/clean
├── docker-compose.yml # Live two-layer stack (fetcher + serving)
├── pyproject.toml # Exact deps; pytest/ruff/mypy config
└── uv.lock # Locked dependency set (CI uses --locked)
data/, model binaries, and secrets are not stored in the repository.
| Variable | Default | Purpose |
|---|---|---|
PHISHNET_TIER2_MODE |
sealed |
Tier-2 provider: sealed (offline replay), live (fetch + Groq), disabled |
PHISHNET_TIER2_FLOOR |
0.6493 |
Score at/above which Tier 2 runs (testing knob; registered value is lower_edge) |
PHISHNET_TIER2_FAILURE_POLICY |
closed |
Disposition for an unfetchable in-band page: closed (any failure alerts), graded (alert only if Tier-1 ≥ PHISHNET_TIER2_FAILURE_FLOOR), mechanism (dispose by failure type — http_403/blocked alert across the band; dns/refused/tls ≥ 0.70; origin_timeout ≥ 0.80; http_404/internal RPC → can't assess) |
PHISHNET_TIER2_FAILURE_FLOOR |
unset | Only used when PHISHNET_TIER2_FAILURE_POLICY=graded |
PHISHNET_FETCHER_URL |
— | Fetcher endpoint for live mode (e.g. http://fetcher:8100/fetch) |
GROQ_API_KEY |
— | Required by live mode; also read from .env |
PHISHNET_EXTENSION_ID |
pinned ID | CORS allowlist for the browser extension |
PHISHNET_ML_ASSETS_DIR |
packaged urlset_ml_assets/ |
Where the verified model artifacts live (/app/models in the image) |
PHISHNET_THRESHOLDS_FILE |
reports/phase4.json |
Threshold source, verified at startup |
PHISHNET_MODELS_BASE_URL |
release URL | Mirror for verified_download (SHA256 still enforced) |
TLDEXTRACT_CACHE |
.tld_cache |
Pinned public-suffix snapshot location |
TRANCO_API_KEY, TRANCO_ACCOUNT_EMAIL |
— | Tranco list resolution in collect.py |
PHISHTANK_KEY |
— | PhishTank feed pull in collect.yml |
AWS_PROFILE, AWS_REGION |
— | boto3 credentials/region for the Common Crawl Athena fetch |
ATHENA_OUTPUT (Makefile) |
s3://phishnet-athena/hosted/ |
Athena query-results bucket |
# score + disposition (Tier 1 always; Tier 2 only for in-band rows)
curl -X POST localhost:8000/predict -H 'Content-Type: application/json' \
-d '{"url": "https://example.com/login"}'
# native LightGBM SHAP for the scoring model (the old 501 is gone)
curl -X POST localhost:8000/explain -H 'Content-Type: application/json' \
-d '{"url": "https://example.com/login", "top_k": 5}'
curl localhost:8000/healthAgainst production, replace localhost:8000 with
https://phishnet-serving-mz5maa3blq-uc.a.run.app.
/predict returns disposition (alert / allow / can't assess), score
(the verdict score, null when unresolved), tier1_score, in_band,
reason, tier2_mode (sealed / live / disabled), tier2 (the judgment
or failure), and the model_hash / thresholds_source provenance. /explain
returns top-k native tree-SHAP {feature, contribution} plus the bias term,
from the model that scored. /health reports the pinned hashes, thresholds,
and Tier-2 mode.
Live on Google Cloud Run (free tier, $0.00/mo at <1% of quota for
~9k URLs/mo) as two scale-to-zero services — full plan in
docs/deployment-plan.md:
| Service | Image | Size | Concurrency | Role |
|---|---|---|---|---|
phishnet-serving |
backend/Dockerfile |
512 MiB / 1 vCPU | 80 | Tier-1 LightGBM fast path + cascade |
phishnet-fetcher |
backend/fetcher/Dockerfile |
1.5 GiB / 1 vCPU | 10 | Tier-2 Playwright Chromium sandbox |
- Model baking: row-(a) weights are downloaded and SHA256-verified in a
RUNbuild step into/app/models, so containers boot with zero network I/O — cold starts stay <800 ms andTier1Servableonly re-verifies hashes in-memory (<2 ms). - Browser tuning: Chromium launches with memory-constrained flags
(
--no-sandbox --disable-dev-shm-usage --disable-gpu --no-zygote --single-process, single source of truthCHROMIUM_ARGSinsrc/phishnet/fetcher/app.py), ~350–500 MB RSS per worker. - Budgets: origin fetch 8 s, provider→fetcher RPC 25 s; Groq spend guard
(
src/phishnet/llm/budget.py) fails closed, and an unwritable ledger maps to aTier2Outcome("failure", ...)instead of a 500. - Secrets:
GROQ_API_KEYlives in Secret Manager (groq-api-key), mounted via--set-secrets— never in the image or the repo. - Deploy:
PROJECT_ID=… REGION=… GROQ_API_KEY=gsk_… ./deploy/deploy_cloudrun.sh(idempotent: registry → build/push → secret → fetcher → serving), thenSERVING_URL=… ./deploy/smoke.shchecks/health,/predict,/explain. Declarative fallbacks indeploy/cloudrun/*.yaml.
eval.py takes any predictor with .name and .score(urls) and emits a
fixed report. Splits are temporal on the phishing side and
registrable-domain-hashed on the benign side — no per-URL random splitting,
no rebalancing, and train/test share zero eTLD+1 (public-suffix-aware).
- Headline metric: PR-AUC (
average_precision_score). - Recall at FPR ≤ 0.5% and ≤ 1.0%, walking real score values (ties respected, never interpolated), with the achieved FPR reported.
- Deployable numbers fix the threshold on held-out data first; a test-swept threshold is labeled unattainable.
- Bootstrap CIs resample by registrable domain, not by row.
- Splits are canonical CRLF bytes (
.gitattributes), so hashes are byte-identical across platforms. - A shape-only canary (
url_shape_canary) audits every corpus for collection leakage; aLEAKINGverdict is a hard stop.
| target | recall | achieved FPR | verdict |
|---|---|---|---|
0.5% (t_alert = 0.9269) |
50.4% | 0.40% | indistinguishable |
1.0% (t_1pct = 0.8781) |
60.7% | 0.98% | indistinguishable |
Domain age is ineligible for the headline (it failed its contamination gate);
its conditional lift and the cold-start cost (53.4%) are in reports/phase3.md.
The Phase 2 gbm_refit lineage's fixed-threshold attempts missed the 0.5%
budget (58.93% @ 0.60%); that refusal is kept in
reports/phase2-eval-gbm-refit.md. The shape confound and the corpus refusals
are in docs/splits-eval-audit.md and docs/WAIVERS.md.
Reproduce the frozen populations (no model artifacts needed):
python -m pytest -m golden --strict-markers -p no:cacheprovider -q
python repro/check_golden.py
make eval-split OUT=$RUNNER_TEMP/repro
python repro/verify.py --hashes repro/hashes.json --dir $RUNNER_TEMP/reproAdd a predictor:
class MyModel:
name = "lgbm-v3"
def score(self, urls: list[str]) -> list[float]:
return self.model.predict_proba(featurise(urls))[:, 1].tolist()make eval PRED=mymodule:MyModelRuntime model files are deployment artifacts and are never committed. The served artifacts are the Phase 3 row (a) pair:
ablation_lexical_gbm_model.pkl— the champion weightsablation_lexical_feature_columns.pkl— the pinned 79-column vocabulary
The legacy urlset_ensemble_model.pkl / scaler.pkl / feature_columns.pkl
remain in the manifest only for the frozen Phase 1–2 eval path; they are no
longer served.
uv sync
uv run python -m phishnet.verified_download # all manifest artifacts
# Tier-1 image fetches just the two row (a) artifacts:
uv run python -m phishnet.verified_download \
--only ablation_lexical_gbm_model.pkl \
--only ablation_lexical_feature_columns.pklThe loader resolves the asset directory via $PHISHNET_ML_ASSETS_DIR,
falling back to src/phishnet/urlset_ml_assets/.
Requires Python >=3.10 and uv:
uv sync --locked # locked deps, incl. dev tools
uv run ruff check . && uv run ruff format --check .
uv run mypy src tests ml_training # strict type-check
uv run pytest # test suite (tests/)GitHub Actions runs the same on every push and pull request (.github/workflows/ci.yml),
plus a reproducibility job (repro.yml) that rebuilds the frozen population
and verifies its hashes.
Continuous integration and delivery run on GitHub Actions (.github/workflows/),
with status badges at the top of this file.
| Workflow | Trigger | Role |
|---|---|---|
ci.yml |
push, pull_request | Locked install, SHA256-verified artifact fetch, lint (ruff), strict typing (mypy), tests (pytest) |
eval.yml |
push to master, pull_request |
Runs the eval harness and posts a sticky PR comment; any PR that moves recall backwards fails |
repro.yml |
push, pull_request | Reproducibility gate: rebuilds the pinned evaluation population and verifies it byte-for-byte |
collect.yml |
workflow_dispatch only | Daily 03:17 UTC schedule disabled 2026-09-23 (corpus complete); manual snapshots still available |
phase4-forward.yml |
workflow_dispatch only | Daily 04:42 UTC schedule disabled 2026-09-23; manual forward collection still available |
| doc | what it covers |
|---|---|
docs/phase3-preregistration.md |
the Phase 3 protocol (the clearest evidence of how the work was run) |
docs/phase4-preregistration.md |
the LLM layer design (unanswered, bounded) |
docs/phase5-preregistration.md |
the adversarial-hardening protocol |
docs/phase6-preregistration.md |
the serving/demo protocol and amendments A–F |
docs/phase7-preregistration.md |
the finalization/packaging protocol |
docs/model-card.md |
intended use, leaks, cold start, calibration shelf life |
docs/architecture.md |
serving topology with measured latency/cost annotations |
docs/adversarial.md |
Phase 5 pointer page |
docs/production-gaps.md |
measured gaps and future work (§7 withdrawn, §8 webflow.io) |
docs/live-performance-plan.md |
live-performance remediation plan: root causes, Tier-1/Tier-2 change inventory, decisions (R6 detector fix, T2-9 risk-graded fail-closed) |
reports/live-eval.md |
live diagnostic: FN/FP decomposition on a labeled live set + 480-URL Tranco benign arm |
docs/llm-local-implementation-plan.md |
local-LLM plan (grounding → offline features → fine-tuning) sized to the measured 4 GB VRAM host |
docs/deployment-plan.md |
zero-cost production deployment on Google Cloud Run: dual-service serverless topology, cold-start optimization, and quota math |
docs/chrome-extension-id-cors.md |
Web Store extension-ID vs CORS review: root cause, live verification, hybrid origin solution |
docs/extension-packaging-deployment.md |
packing the extension for the Chrome Web Store (packaging script, listing, submission) |
docs/webstore-listing.md |
copy-paste store submission text, permission justifications, asset checklist |
docs/privacy-policy.md (+ root privacy.html) |
privacy policy source and its hosted Web Store copy (https://bucke200.github.io/PhishNet/privacy.html via Pages on master + / (root)) |
docs/point-in-time.md |
point-in-time feature discipline |
docs/splits-eval-audit.md, docs/WAIVERS.md |
shape audit and unregenerable populations |
docs/cc-benign-acquisition.md, docs/aws-athena-iam-policy.json |
Common Crawl/Athena runbook and IAM policy |
reports/phase3.md, reports/phase5-adversarial.md, reports/phase6.md, reports/phase7.md |
results |
docs/roadmap.md |
phase history and future work |
Live extension notifications against the production backend:
Tier-1 phishing (no Tier-2 needed): quesnel.docu462589.pro scored
0.9861 ≥ t_alert — Chrome Safe Browsing independently flagged the same
domain.
Tier-1 benign: Codeforces profile at 0.6047 < lower_edge — allowed
without spending a Tier-2 call.
Tier-2 benign: a LambdaTest login page scored in-band, was fetched and
rendered, and the LLM judged it benign — allow (tier2_benign).
MIT License
Copyright 2025–2026 Srinjay Panja
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- This repository does not store model/data files or virtual environments
(see
.gitignore); model binaries ship as GitHub Release assets. .gitattributesdeclares LFS filters for*.pkl, but nothing in the repo uses LFS.


