Skip to content

Configuration & logging

varagity.config

Typed application configuration.

Settings load from the process environment and the repo-root .env file (see .env.example). Modules read the :class:Settings object obtained via :func:get_settings — never os.getenv — so configuration stays typed, validated, and mockable in tests.

Settings

Bases: BaseSettings

Application settings loaded from the environment and .env.

.env also carries lowercase Docker Compose interpolation variables (e.g. embeddings_volume), so unknown keys are ignored rather than rejected.

Defaults are the in-container values (see .env.example); host runs override them via .env per the host-vs-container comment convention.

Attributes:

Name Type Description
LOG_LEVEL str

Root logging level name (e.g. "INFO", "DEBUG").

DEFAULT_VERBOSE int

Default console verbosity for public functions; 0 = off, 1 = low (names, counts), 2 = high (full metadata, panels).

DOCS_PATH str

Directory scanned for the ingest corpus.

ALLOWED_EXTENSIONS str

Comma-separated whitelist of ingestable file extensions. v2 widened the v1 .pdf/.txt/.md set with the office/web formats (spec_v2 §8.1); the post-v3 widening adds .rst, .csv, OpenDocument (.odt/.ods/.odp), the Office macro/template variants, .xhtml, and bitmap images (.png/.jpg/… — OCR'd by the image parser).

PDF_OCR_FALLBACK bool

Whether a PDF whose text-layer extraction comes up (near-)empty is automatically re-converted with OCR (plan decision #10). Off = pass 1's result stands and a textless PDF ends in the empty-extraction guard.

PDF_OCR_MIN_CHARS int

Below this many non-whitespace characters a pass-1 extraction counts as "no text layer" and triggers the OCR fallback.

PDF_OCR_TEXTLESS_PAGE_RATIO float

Textless-page share at or above which the OCR fallback triggers (catches mixed scanned/digital documents whose digital pages alone pass the length check).

PDF_OCR_FORCE_FULL_PAGE bool

Escape hatch for corrupt-text-layer PDFs (garbage embedded text passes the content triggers): skip pass 1 and OCR every page, ignoring embedded text. Off by default — digital pages of mixed documents keep their text layer.

OCR_ENGINE str

Registry name of the OCR engine used by the fallback (easyocr | tesseract; see varagity.ingest.parsers.pdf.OCR_ENGINE_FACTORIES). EasyOCR is the benchmark-decided default (ADR-004): error-free on the fixture scans where Tesseract drops words; Tesseract trades that accuracy for ~5× throughput.

OCR_LANGUAGES str

Comma-separated ISO 639-1 language codes for OCR, primary language first (mapped per engine, e.g. en → Tesseract's eng).

PREVIEW_ENABLED bool

Kill switch for the evidence-panel page preview (ADR-010) — the RERANK_ENABLED pattern: off, the preview routes degrade (available:false, reason:"preview_disabled") and the GUI falls back to the full-text view. Env-only by design (not runtime-overridable).

PREVIEW_RENDER_WIDTH int

Rendered page-image width in pixels for GET /api/documents/{doc_id}/preview/page/{page} (512–4096; height follows the page's aspect ratio).

PREVIEW_MIN_COVERAGE float

Word-trigram coverage floor in [0.0, 1.0] below which a preview locate reports no_match instead of guessing a page.

PREVIEW_CONVERT_TIMEOUT_S int

Timeout in seconds for one LibreOffice PPTX→PDF conversion (the preview path's only subprocess).

CHUNKING_STRATEGY str

Registry name of the chunking strategy (recursive_character | token_based | markdown_aware | docling_hybrid | semantic — see varagity.chunking; spec_v2 §7). Changing it doesn't change content hashes, so an unchanged corpus needs ingest --reingest to re-chunk.

CHUNK_SIZE int

Chunk size budget. The unit is per strategy (each module documents its own): characters for recursive_character and markdown_aware (spec §9.3); tokens for token_based, docling_hybrid, and semantic (aligned to e5's 512-token ceiling — spec_v2 §7).

CHUNK_OVERLAP int

Overlap between consecutive chunks, in the same unit as CHUNK_SIZE for that strategy (unused by docling_hybrid, which merges instead of overlapping).

CONTEXTUALIZE bool

Whether ingestion generates an LLM situating blurb per chunk (Contextual Retrieval, spec §9.4). False keeps the identity path (contextualized_content = content) — the non-contextual eval baseline and a throughput knob (plan decision #2). Toggling it does not change content hashes, so re-processing an unchanged corpus needs ingest --reingest.

EMBEDDING_MODEL str

Served model name passed to the embeddings API (the infinity INFINITY_SERVED_MODEL_NAME string, verbatim).

EMBEDDING_API_URL str

OpenAI-compatible base URL of the infinity server.

EMBEDDING_API_KEY str

Bearer token for the infinity server.

EMBEDDING_DIM int

Embedding dimensionality (1024 for e5-large-instruct).

EMBEDDING_BATCH_SIZE int

Number of passages sent per embeddings request.

BASE_MODEL str

Filename of the llama.cpp .gguf model, relative to the bind-mounted ${models_volume} directory.

BASE_MODEL_API_URL str

OpenAI-compatible base URL of the llama.cpp server.

BASE_MODEL_API_KEY str

Bearer token for the llama.cpp server (it runs unauthenticated in v1, but the OpenAI client requires a value).

MAX_TOKENS int

Generation cap per LLM response.

LLM_TEMPERATURE float

Sampling temperature for LLM responses.

LLM_CONTEXT_TOKENS int

The served model's context window, in tokens — must mirror the llama.cpp --ctx-size in docker-compose.yml. llama.cpp (context shift disabled, its default) fails a request with a hard 500 once prompt + generated tokens reach the window, so generation caps are clamped against this value (:class:varagity.models.llm.LLMClient).

CONTEXTUALIZE_MAX_TOKENS int

Generation cap for the per-chunk situating blurbs (spec §9.4) — sized for a reasoning preamble plus a short blurb, deliberately far below the chat-sized MAX_TOKENS: an 8k reserve against a 16k window rejects any document over ~8k tokens. A blurb that overruns the cap degrades to "no context" via the empty-blurb path, never a failed ingest.

CHAT_MODEL_TYPE str

Model-registry type the chat surfaces (API + GUI) resolve their LLM with (default | reasoning | tool — the LLM aliases of :func:varagity.models.registry.get_model; spec_v2 §4.7's Generation "model type" knob). All three resolve to the single llama.cpp server in v2; the knob exists so the composer quick-toggle survives the aliases becoming separate servers post-v2.

CHAT_ENGINE str

Registry name of the chat engine preparing each turn's retrieval query (spec_v3 §4.2; see varagity.chat). simple searches with the user's words, verbatim (the stateless v2 behavior); condense_context rewrites follow-ups into standalone search queries against the conversation history.

CONDENSE_ENABLED bool

Kill switch for the condense stage, orthogonal to engine selection (spec_v3 §4.6 — the RERANK_ENABLED pattern): with CHAT_ENGINE=condense_context and this off, the engine degrades to simple behavior (the identity split) and logs it.

CONDENSE_MODEL_TYPE str

Model-registry type the condense stage resolves its LLM with when the caller injects none (default | reasoning | tool — mirrors CHAT_MODEL_TYPE). All three are one llama.cpp server today; the knob is the seam for pointing condensing at a smaller model without a code change.

CONDENSE_HISTORY_TURNS int

Maximum prior messages loaded for and fed to the condenser (6 = three user/assistant pairs). 0 disables history — every turn then takes the no-history path and never condenses.

CONDENSE_MAX_TOKENS int

Generation cap for the condense call — sized for a reasoning preamble plus a one-sentence query, the CONTEXTUALIZE_MAX_TOKENS lesson over again: a reasoning model thinks before it answers, and a cap it can't finish under returns empty content, silently degrading every turn to the raw-query fallback (measured live in the chat-engine eval — 128 starved the condenser on 11 of 11 follow-ups; a typical condense spends ~215 thinking tokens first).

CONDENSE_MAX_CHARS int

Length ceiling on the cleaned condense output; anything longer means the condenser misbehaved, and the raw query is searched instead (the spec_v3 §4.6 fallback).

POSTGRES_HOST str

PostgreSQL host (service name in-container).

POSTGRES_PORT int

PostgreSQL port.

POSTGRES_DB str

PostgreSQL database name.

POSTGRES_USER str

PostgreSQL user.

POSTGRES_PASSWORD str

PostgreSQL password (dev-only static credential).

ELASTICSEARCH_URL str

Base URL of the Elasticsearch server (BM25 store).

BM25_INDEX_NAME str

Name of the contextual BM25 index.

PREFECT_API_URL str

Base URL of the Prefect server's API. Exported to the process environment by varagity.pipeline before prefect is imported (Prefect captures its environment at import time), so flow/task runs are tracked by the compose prefect service.

RETRIEVAL_METHOD str

Registry name of the retrieval method (spec §10.1 + spec_v2 §5 + ADR-016: semantic | bm25 | hybrid | reranked | hyde; the default is hybrid).

TOP_K int

Number of chunks retrieved per query.

SEMANTIC_WEIGHT float

Hybrid rank-fusion weight of the semantic (pgvector) arm (spec §11.4). Must sum to 1.0 with BM25_WEIGHT.

BM25_WEIGHT float

Hybrid rank-fusion weight of the BM25 (Elasticsearch) arm. Must sum to 1.0 with SEMANTIC_WEIGHT.

RERANK_ENABLED bool

Kill switch for the cross-encoder stage, orthogonal to method selection (spec_v2 §5.2): with RETRIEVAL_METHOD=reranked and this off, the reranked retriever degrades to its base method's ranking and logs it.

RERANK_MODEL str

Served reranker name passed to the infinity /rerank endpoint, verbatim. Must be a cross-encoder (bge-reranker-v2-m3); infinity structurally rejects bi-encoders (e5, jina) at /rerank.

RERANK_API_URL str

OpenAI-style base URL of the infinity server serving the reranker (/rerank lives under the same /v1 prefix as embeddings).

RERANK_API_KEY str

Bearer token for the reranker (the same infinity key as embeddings).

RERANK_TOP_N int

Documents kept after re-ranking. Must be positive and ≤ RERANK_CANDIDATES; with RETRIEVAL_METHOD=reranked also ≤ TOP_K (rerank narrows; it can't invent candidates).

RERANK_BASE_METHOD str

Registry name of the retriever the reranked method composes (semantic | bm25 | hybrid | hyde; not reranked — no recursion). hyde here is the HyDE+rerank pairing (ADR-016): HyDE steers the candidate pool, the cross-encoder judges the user's real query.

RERANK_CANDIDATES int

Candidate-pool size over-fetched from the base retriever and cross-encoded per query (v2 plan decision #3 — the Anthropic cookbook's 150→20 over-fetch, scaled to this corpus).

HYDE_ENABLED bool

Kill switch for the hypothetical-passage stage, orthogonal to method selection (ADR-016 — the RERANK_ENABLED pattern): with RETRIEVAL_METHOD=hyde and this off, the hyde retriever degrades to its base method's raw-query retrieval and logs it.

HYDE_BASE_METHOD str

Registry name of the retriever the hyde method composes (semantic | hybrid). Not bm25 — it never consumes a query vector, so HyDE over it would be a paid LLM call with zero effect; not reranked — the cross-encoder must judge the user's real query, so rerank composes hyde (RERANK_BASE_METHOD=hyde), never the reverse; not hyde — no recursion.

HYDE_MODEL_TYPE str

Model-registry type the passage generator resolves its LLM with (default | reasoning | tool — mirrors CONDENSE_MODEL_TYPE, and like it is the seam for pointing HyDE at a smaller model without a code change).

HYDE_MAX_TOKENS int

Generation cap for the hypothetical passage — sized for a reasoning preamble plus a 3–5 sentence passage (the CONDENSE_MAX_TOKENS lesson: a cap a reasoning model can't finish under returns empty content and silently degrades the query to the raw-query fallback; measured live, 512 starved roughly a third of generations on this stack's reasoning model — a passage needs more room than a one-sentence condense).

HYDE_MAX_CHARS int

Length ceiling on the cleaned passage; anything longer means the generator misbehaved, and the raw query is searched instead (roughly a chunk's worth — e5 truncates the embedded probe at 512 tokens regardless).

API_HOST str

Bind address of the HTTP API server (spec_v2 §4.1).

API_PORT int

Port of the HTTP API server.

API_CORS_ORIGINS str

Comma-separated origins allowed CORS access to the API (the web app's origin; dev-only posture — spec_v2 §14).

UPLOAD_MAX_MB int

Per-file size cap for corpus uploads (POST /api/documents, spec_v2 §4.2).

UPLOAD_MAX_FILES int

Per-request file-count cap for corpus uploads (spec_v3 §5.2) — a dragged home directory gets a clean 422 instead of a filled disk.

UPLOAD_MAX_TOTAL_MB int

Per-request size cap summed across every file of one upload (UPLOAD_MAX_MB stays the per-file cap; this bounds the batch).

UPLOAD_MAX_PATH_DEPTH int

Maximum path segments (folders plus the file name) a relative upload path may carry (spec_v3 §5.2 — folder uploads preserve structure, bounded).

METRICS_ENABLED bool

Whether the API serves GET /metrics (spec_v2 §6). Gates the endpoint only — the in-app collectors always record (cheap in-memory counters).

PROMETHEUS_PORT int

Host port the compose prometheus service binds (compose interpolation; the app never dials it).

GRAFANA_PORT int

Host port the compose grafana service binds (compose interpolation; the container serves 3000 internally).

GRAPH_ENABLED bool

Master kill switch for the graph subsystem (ADR-017's degrade semantics). Off, the graph corpus routes answer a structured 403 graph_disabled and a graph-targeted chat turn degrades to a chunk-RAG answer — never a silent no-op. Selection and the toggle stay orthogonal, exactly as RERANK_ENABLED and CONDENSE_ENABLED do.

GRAPH_DOCS_PATH str

Directory holding the GraphRAG message corpus (copied iMessage chat.db files; spec_graphrag §10.2, Q4) — a separate root from DOCS_PATH, gitignored like it, never mixed into the chunk-RAG corpus.

GRAPH_UPLOAD_MAX_MB int

Per-file size cap for graph corpus uploads (POST /api/graph/documents). Far above UPLOAD_MAX_MB on purpose: that cap is document-sized, while a decade of iMessage history is one multi-gigabyte SQLite file.

GRAPH_STORAGE_PATH str

Root for the graph engine's self-stored working directory (<root>/<engine>/; ADR-017 pinned file storage). In compose this is the graphdata named volume, mounted into the api service only: the API process is the single writer, which is why there is deliberately no CLI graph build.

GRAPH_ENGINE str

Registry name of the graph engine (:data:varagity.graph.base.GRAPH_ENGINE_REGISTRY). Env-only — switching engines re-indexes the corpus from scratch, which is not a settings-drawer action. ADR-017 chose lightrag.

GRAPH_OWNER_ALIASES str

Comma-separated names the owner is referred to by in message text (e.g. "John,John Doe"); the first is the owner's display label (:attr:graph_owner_label). iMessage ego is structural (is_from_me); these only cover in-text mentions (spec_graphrag §10.1, Q6).

GRAPH_HANDLE_NAMES str

Comma-separated handle=Name pairs mapping raw iMessage handles (phone/email) to display names (e.g. "+15551234567=Bob,jane@x.com=Jane"); unmapped handles fall back to the raw handle. Merged over the file's entries by :attr:graph_handle_name_map (an inline pair wins).

GRAPH_HANDLE_NAMES_FILE str

Optional path to a contacts file holding the same handle=Name pairs, one per line (trailing commas fine) — the bulk alternative to the inline setting (e.g. "./graph-docs/contacts.txt"). Empty = no file.

GRAPH_QUERY_MODE str

Engine query mode a graph-targeted chat turn retrieves with (local | global | hybrid | naive | mix; :data:varagity.graph.engines.lightrag.QUERY_MODES is the vocabulary this validator mirrors). mix is the ADR-017 acceptance gate's measured winner — it fuses the dual-level keyword retrieval with the chunk plane, which is what the transcript-grounded answers needed. Runtime-mutable (stage-2 decision #2), unlike GRAPH_ENGINE: re-reading the graph a different way costs a query, not a re-index.

GRAPH_QUERY_PREFIX bool

Whether graph-engine queries are wrapped in e5's asymmetric instruction format (spec §9.5; :func:varagity.models.embeddings.format_query owns the format). Passages are never prefixed either way — which is what e5 requires of documents — so this changes retrieval without re-embedding the graph. Off was the ADR-017 bake-off's recorded behavior; on is the shipped default, measured by the acceptance gate (2026-08-03: prefixed mix scored 0.4216 fact recall against the 0.37 bar).

Source code in varagity/config.py
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
class Settings(BaseSettings):
    """Application settings loaded from the environment and ``.env``.

    ``.env`` also carries lowercase Docker Compose interpolation variables
    (e.g. ``embeddings_volume``), so unknown keys are ignored rather than
    rejected.

    Defaults are the in-container values (see ``.env.example``); host runs
    override them via ``.env`` per the host-vs-container comment convention.

    Attributes:
        LOG_LEVEL: Root logging level name (e.g. ``"INFO"``, ``"DEBUG"``).
        DEFAULT_VERBOSE: Default console verbosity for public functions;
            ``0`` = off, ``1`` = low (names, counts), ``2`` = high (full
            metadata, panels).
        DOCS_PATH: Directory scanned for the ingest corpus.
        ALLOWED_EXTENSIONS: Comma-separated whitelist of ingestable file
            extensions. v2 widened the v1 ``.pdf``/``.txt``/``.md`` set with
            the office/web formats (spec_v2 §8.1); the post-v3 widening adds
            ``.rst``, ``.csv``, OpenDocument (``.odt``/``.ods``/``.odp``),
            the Office macro/template variants, ``.xhtml``, and bitmap
            images (``.png``/``.jpg``/… — OCR'd by the ``image`` parser).
        PDF_OCR_FALLBACK: Whether a PDF whose text-layer extraction comes
            up (near-)empty is automatically re-converted with OCR (plan
            decision #10). Off = pass 1's result stands and a textless PDF
            ends in the empty-extraction guard.
        PDF_OCR_MIN_CHARS: Below this many non-whitespace characters a
            pass-1 extraction counts as "no text layer" and triggers the
            OCR fallback.
        PDF_OCR_TEXTLESS_PAGE_RATIO: Textless-page share at or above which
            the OCR fallback triggers (catches mixed scanned/digital
            documents whose digital pages alone pass the length check).
        PDF_OCR_FORCE_FULL_PAGE: Escape hatch for corrupt-text-layer PDFs
            (garbage embedded text passes the content triggers): skip pass
            1 and OCR every page, ignoring embedded text. Off by default —
            digital pages of mixed documents keep their text layer.
        OCR_ENGINE: Registry name of the OCR engine used by the fallback
            (``easyocr`` | ``tesseract``; see
            ``varagity.ingest.parsers.pdf.OCR_ENGINE_FACTORIES``). EasyOCR
            is the benchmark-decided default (ADR-004): error-free on the
            fixture scans where Tesseract drops words; Tesseract trades
            that accuracy for ~5× throughput.
        OCR_LANGUAGES: Comma-separated ISO 639-1 language codes for OCR,
            primary language first (mapped per engine, e.g. ``en`` →
            Tesseract's ``eng``).
        PREVIEW_ENABLED: Kill switch for the evidence-panel page preview
            (ADR-010) — the ``RERANK_ENABLED`` pattern: off, the preview
            routes degrade (``available:false, reason:"preview_disabled"``)
            and the GUI falls back to the full-text view. Env-only by
            design (not runtime-overridable).
        PREVIEW_RENDER_WIDTH: Rendered page-image width in pixels for
            ``GET /api/documents/{doc_id}/preview/page/{page}`` (512–4096;
            height follows the page's aspect ratio).
        PREVIEW_MIN_COVERAGE: Word-trigram coverage floor in ``[0.0, 1.0]``
            below which a preview locate reports ``no_match`` instead of
            guessing a page.
        PREVIEW_CONVERT_TIMEOUT_S: Timeout in seconds for one LibreOffice
            PPTX→PDF conversion (the preview path's only subprocess).
        CHUNKING_STRATEGY: Registry name of the chunking strategy
            (``recursive_character`` | ``token_based`` | ``markdown_aware``
            | ``docling_hybrid`` | ``semantic`` — see ``varagity.chunking``;
            spec_v2 §7). Changing it doesn't change content hashes, so an
            unchanged corpus needs ``ingest --reingest`` to re-chunk.
        CHUNK_SIZE: Chunk size budget. The **unit is per strategy**
            (each module documents its own): characters for
            ``recursive_character`` and ``markdown_aware`` (spec §9.3);
            **tokens** for ``token_based``, ``docling_hybrid``, and
            ``semantic`` (aligned to e5's 512-token ceiling — spec_v2 §7).
        CHUNK_OVERLAP: Overlap between consecutive chunks, in the same
            unit as ``CHUNK_SIZE`` for that strategy (unused by
            ``docling_hybrid``, which merges instead of overlapping).
        CONTEXTUALIZE: Whether ingestion generates an LLM situating blurb per
            chunk (Contextual Retrieval, spec §9.4). ``False`` keeps the
            identity path (``contextualized_content = content``) — the
            non-contextual eval baseline and a throughput knob (plan
            decision #2). Toggling it does not change content hashes, so
            re-processing an unchanged corpus needs ``ingest --reingest``.
        EMBEDDING_MODEL: Served model name passed to the embeddings API (the
            infinity ``INFINITY_SERVED_MODEL_NAME`` string, verbatim).
        EMBEDDING_API_URL: OpenAI-compatible base URL of the infinity server.
        EMBEDDING_API_KEY: Bearer token for the infinity server.
        EMBEDDING_DIM: Embedding dimensionality (1024 for e5-large-instruct).
        EMBEDDING_BATCH_SIZE: Number of passages sent per embeddings request.
        BASE_MODEL: Filename of the llama.cpp ``.gguf`` model, relative to the
            bind-mounted ``${models_volume}`` directory.
        BASE_MODEL_API_URL: OpenAI-compatible base URL of the llama.cpp server.
        BASE_MODEL_API_KEY: Bearer token for the llama.cpp server (it runs
            unauthenticated in v1, but the OpenAI client requires a value).
        MAX_TOKENS: Generation cap per LLM response.
        LLM_TEMPERATURE: Sampling temperature for LLM responses.
        LLM_CONTEXT_TOKENS: The served model's context window, in tokens —
            must mirror the llama.cpp ``--ctx-size`` in
            ``docker-compose.yml``. llama.cpp (context shift disabled, its
            default) fails a request with a hard 500 once prompt + generated
            tokens reach the window, so generation caps are clamped against
            this value (:class:`varagity.models.llm.LLMClient`).
        CONTEXTUALIZE_MAX_TOKENS: Generation cap for the per-chunk situating
            blurbs (spec §9.4) — sized for a reasoning preamble plus a short
            blurb, deliberately far below the chat-sized ``MAX_TOKENS``: an
            8k reserve against a 16k window rejects any document over ~8k
            tokens. A blurb that overruns the cap degrades to "no context"
            via the empty-blurb path, never a failed ingest.
        CHAT_MODEL_TYPE: Model-registry type the chat surfaces (API + GUI)
            resolve their LLM with (``default`` | ``reasoning`` | ``tool``
            — the LLM aliases of :func:`varagity.models.registry.get_model`;
            spec_v2 §4.7's Generation "model type" knob). All three resolve
            to the single llama.cpp server in v2; the knob exists so the
            composer quick-toggle survives the aliases becoming separate
            servers post-v2.
        CHAT_ENGINE: Registry name of the chat engine preparing each turn's
            retrieval query (spec_v3 §4.2; see ``varagity.chat``). ``simple``
            searches with the user's words, verbatim (the stateless v2
            behavior); ``condense_context`` rewrites follow-ups into
            standalone search queries against the conversation history.
        CONDENSE_ENABLED: Kill switch for the condense stage, orthogonal to
            engine selection (spec_v3 §4.6 — the ``RERANK_ENABLED``
            pattern): with ``CHAT_ENGINE=condense_context`` and this off,
            the engine degrades to ``simple`` behavior (the identity split)
            and logs it.
        CONDENSE_MODEL_TYPE: Model-registry type the condense stage
            resolves its LLM with when the caller injects none (``default``
            | ``reasoning`` | ``tool`` — mirrors ``CHAT_MODEL_TYPE``). All
            three are one llama.cpp server today; the knob is the seam for
            pointing condensing at a smaller model without a code change.
        CONDENSE_HISTORY_TURNS: Maximum prior messages loaded for and fed
            to the condenser (6 = three user/assistant pairs). ``0``
            disables history — every turn then takes the no-history path
            and never condenses.
        CONDENSE_MAX_TOKENS: Generation cap for the condense call — sized
            for a reasoning preamble plus a one-sentence query, the
            ``CONTEXTUALIZE_MAX_TOKENS`` lesson over again: a reasoning
            model thinks before it answers, and a cap it can't finish
            under returns empty content, silently degrading every turn to
            the raw-query fallback (measured live in the chat-engine eval — 128
            starved the condenser on 11 of 11 follow-ups; a typical
            condense spends ~215 thinking tokens first).
        CONDENSE_MAX_CHARS: Length ceiling on the cleaned condense output;
            anything longer means the condenser misbehaved, and the raw
            query is searched instead (the spec_v3 §4.6 fallback).
        POSTGRES_HOST: PostgreSQL host (service name in-container).
        POSTGRES_PORT: PostgreSQL port.
        POSTGRES_DB: PostgreSQL database name.
        POSTGRES_USER: PostgreSQL user.
        POSTGRES_PASSWORD: PostgreSQL password (dev-only static credential).
        ELASTICSEARCH_URL: Base URL of the Elasticsearch server (BM25 store).
        BM25_INDEX_NAME: Name of the contextual BM25 index.
        PREFECT_API_URL: Base URL of the Prefect server's API. Exported to
            the process environment by ``varagity.pipeline`` **before**
            ``prefect`` is imported (Prefect captures its environment at
            import time), so flow/task runs are tracked by the compose
            ``prefect`` service.
        RETRIEVAL_METHOD: Registry name of the retrieval method (spec §10.1
            + spec_v2 §5 + ADR-016: ``semantic`` | ``bm25`` | ``hybrid`` |
            ``reranked`` | ``hyde``; the default is ``hybrid``).
        TOP_K: Number of chunks retrieved per query.
        SEMANTIC_WEIGHT: Hybrid rank-fusion weight of the semantic (pgvector)
            arm (spec §11.4). Must sum to 1.0 with ``BM25_WEIGHT``.
        BM25_WEIGHT: Hybrid rank-fusion weight of the BM25 (Elasticsearch)
            arm. Must sum to 1.0 with ``SEMANTIC_WEIGHT``.
        RERANK_ENABLED: Kill switch for the cross-encoder stage, orthogonal
            to method selection (spec_v2 §5.2): with
            ``RETRIEVAL_METHOD=reranked`` and this off, the ``reranked``
            retriever degrades to its base method's ranking and logs it.
        RERANK_MODEL: Served reranker name passed to the infinity
            ``/rerank`` endpoint, verbatim. Must be a cross-encoder
            (``bge-reranker-v2-m3``); infinity structurally rejects
            bi-encoders (e5, jina) at ``/rerank``.
        RERANK_API_URL: OpenAI-style base URL of the infinity server
            serving the reranker (``/rerank`` lives under the same ``/v1``
            prefix as embeddings).
        RERANK_API_KEY: Bearer token for the reranker (the same infinity
            key as embeddings).
        RERANK_TOP_N: Documents kept after re-ranking. Must be positive and
            ≤ ``RERANK_CANDIDATES``; with ``RETRIEVAL_METHOD=reranked``
            also ≤ ``TOP_K`` (rerank narrows; it can't invent candidates).
        RERANK_BASE_METHOD: Registry name of the retriever the ``reranked``
            method composes (``semantic`` | ``bm25`` | ``hybrid`` |
            ``hyde``; not ``reranked`` — no recursion). ``hyde`` here is
            the HyDE+rerank pairing (ADR-016): HyDE steers the candidate
            pool, the cross-encoder judges the user's real query.
        RERANK_CANDIDATES: Candidate-pool size over-fetched from the base
            retriever and cross-encoded per query (v2 plan decision #3 —
            the Anthropic cookbook's 150→20 over-fetch, scaled to this
            corpus).
        HYDE_ENABLED: Kill switch for the hypothetical-passage stage,
            orthogonal to method selection (ADR-016 — the
            ``RERANK_ENABLED`` pattern): with ``RETRIEVAL_METHOD=hyde`` and
            this off, the ``hyde`` retriever degrades to its base method's
            raw-query retrieval and logs it.
        HYDE_BASE_METHOD: Registry name of the retriever the ``hyde``
            method composes (``semantic`` | ``hybrid``). Not ``bm25`` —
            it never consumes a query vector, so HyDE over it would be a
            paid LLM call with zero effect; not ``reranked`` — the
            cross-encoder must judge the user's real query, so rerank
            composes hyde (``RERANK_BASE_METHOD=hyde``), never the
            reverse; not ``hyde`` — no recursion.
        HYDE_MODEL_TYPE: Model-registry type the passage generator
            resolves its LLM with (``default`` | ``reasoning`` | ``tool``
            — mirrors ``CONDENSE_MODEL_TYPE``, and like it is the seam for
            pointing HyDE at a smaller model without a code change).
        HYDE_MAX_TOKENS: Generation cap for the hypothetical passage —
            sized for a reasoning preamble plus a 3–5 sentence passage
            (the ``CONDENSE_MAX_TOKENS`` lesson: a cap a reasoning model
            can't finish under returns empty content and silently degrades
            the query to the raw-query fallback; measured live, 512
            starved roughly a third of generations on this stack's
            reasoning model — a passage needs more room than a
            one-sentence condense).
        HYDE_MAX_CHARS: Length ceiling on the cleaned passage; anything
            longer means the generator misbehaved, and the raw query is
            searched instead (roughly a chunk's worth — e5 truncates the
            embedded probe at 512 tokens regardless).
        API_HOST: Bind address of the HTTP API server (spec_v2 §4.1).
        API_PORT: Port of the HTTP API server.
        API_CORS_ORIGINS: Comma-separated origins allowed CORS access to
            the API (the web app's origin; dev-only posture — spec_v2 §14).
        UPLOAD_MAX_MB: Per-file size cap for corpus uploads
            (``POST /api/documents``, spec_v2 §4.2).
        UPLOAD_MAX_FILES: Per-request file-count cap for corpus uploads
            (spec_v3 §5.2) — a dragged home directory gets a clean 422
            instead of a filled disk.
        UPLOAD_MAX_TOTAL_MB: Per-request size cap summed across every file
            of one upload (``UPLOAD_MAX_MB`` stays the per-file cap; this
            bounds the batch).
        UPLOAD_MAX_PATH_DEPTH: Maximum path segments (folders plus the file
            name) a relative upload path may carry (spec_v3 §5.2 — folder
            uploads preserve structure, bounded).
        METRICS_ENABLED: Whether the API serves ``GET /metrics`` (spec_v2
            §6). Gates the endpoint only — the in-app collectors always
            record (cheap in-memory counters).
        PROMETHEUS_PORT: Host port the compose ``prometheus`` service
            binds (compose interpolation; the app never dials it).
        GRAFANA_PORT: Host port the compose ``grafana`` service binds
            (compose interpolation; the container serves 3000 internally).
        GRAPH_ENABLED: Master kill switch for the graph subsystem (ADR-017's
            degrade semantics). Off, the graph corpus routes answer a
            structured ``403 graph_disabled`` and a graph-targeted chat turn
            degrades to a chunk-RAG answer — never a silent no-op. Selection
            and the toggle stay orthogonal, exactly as ``RERANK_ENABLED`` and
            ``CONDENSE_ENABLED`` do.
        GRAPH_DOCS_PATH: Directory holding the GraphRAG message corpus
            (copied iMessage ``chat.db`` files; spec_graphrag §10.2, Q4) — a
            separate root from ``DOCS_PATH``, gitignored like it, never mixed
            into the chunk-RAG corpus.
        GRAPH_UPLOAD_MAX_MB: Per-file size cap for graph corpus uploads
            (``POST /api/graph/documents``). Far above ``UPLOAD_MAX_MB``
            on purpose: that cap is document-sized, while a decade of
            iMessage history is one multi-gigabyte SQLite file.
        GRAPH_STORAGE_PATH: Root for the graph engine's self-stored working
            directory (``<root>/<engine>/``; ADR-017 pinned file storage). In
            compose this is the ``graphdata`` named volume, mounted into the
            **api** service only: the API process is the single writer, which
            is why there is deliberately no CLI graph build.
        GRAPH_ENGINE: Registry name of the graph engine
            (:data:`varagity.graph.base.GRAPH_ENGINE_REGISTRY`). Env-only —
            switching engines re-indexes the corpus from scratch, which is
            not a settings-drawer action. ADR-017 chose ``lightrag``.
        GRAPH_OWNER_ALIASES: Comma-separated names the owner is referred to
            by *in message text* (e.g. ``"John,John Doe"``); the first is
            the owner's display label (:attr:`graph_owner_label`). iMessage
            ego is structural (``is_from_me``); these only cover in-text
            mentions (spec_graphrag §10.1, Q6).
        GRAPH_HANDLE_NAMES: Comma-separated ``handle=Name`` pairs mapping raw
            iMessage handles (phone/email) to display names (e.g.
            ``"+15551234567=Bob,jane@x.com=Jane"``); unmapped handles fall
            back to the raw handle. Merged over the file's entries by
            :attr:`graph_handle_name_map` (an inline pair wins).
        GRAPH_HANDLE_NAMES_FILE: Optional path to a contacts file holding
            the same ``handle=Name`` pairs, one per line (trailing commas
            fine) — the bulk alternative to the inline setting (e.g.
            ``"./graph-docs/contacts.txt"``). Empty = no file.
        GRAPH_QUERY_MODE: Engine query mode a graph-targeted chat turn
            retrieves with (``local`` | ``global`` | ``hybrid`` | ``naive`` |
            ``mix``; :data:`varagity.graph.engines.lightrag.QUERY_MODES` is
            the vocabulary this validator mirrors). ``mix`` is the ADR-017
            acceptance gate's measured winner — it fuses the dual-level
            keyword retrieval with the chunk plane, which is what the
            transcript-grounded answers needed. Runtime-mutable (stage-2
            decision #2), unlike ``GRAPH_ENGINE``: re-reading the graph a
            different way costs a query, not a re-index.
        GRAPH_QUERY_PREFIX: Whether graph-engine *queries* are wrapped in
            e5's asymmetric instruction format (spec §9.5;
            :func:`varagity.models.embeddings.format_query` owns the format).
            Passages are never prefixed either way — which is what e5
            requires of documents — so this changes retrieval **without**
            re-embedding the graph. Off was the ADR-017 bake-off's recorded
            behavior; **on** is the shipped default, measured by the
            acceptance gate (2026-08-03: prefixed ``mix`` scored 0.4216 fact
            recall against the 0.37 bar).
    """

    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    LOG_LEVEL: str = "INFO"
    DEFAULT_VERBOSE: int = 1

    DOCS_PATH: str = "./docs"
    ALLOWED_EXTENSIONS: str = (
        ".pdf,.txt,.md,.rst,"
        ".docx,.docm,.dotx,.dotm,.pptx,.pptm,.potx,.potm,.ppsx,.ppsm,.xlsx,.xlsm,"
        ".csv,.odt,.ods,.odp,.html,.htm,.xhtml,"
        ".png,.jpg,.jpeg,.tif,.tiff,.bmp,.webp"
    )

    PDF_OCR_FALLBACK: bool = True
    PDF_OCR_MIN_CHARS: int = 50
    PDF_OCR_TEXTLESS_PAGE_RATIO: float = 0.2
    PDF_OCR_FORCE_FULL_PAGE: bool = False
    OCR_ENGINE: str = "easyocr"  # benchmark-decided default (ADR-004)
    OCR_LANGUAGES: str = "en"

    PREVIEW_ENABLED: bool = True
    PREVIEW_RENDER_WIDTH: int = 1536
    PREVIEW_MIN_COVERAGE: float = 0.3
    PREVIEW_CONVERT_TIMEOUT_S: int = 120

    CHUNKING_STRATEGY: str = "recursive_character"
    CHUNK_SIZE: int = 400  # characters, not tokens (spec §9.3)
    CHUNK_OVERLAP: int = 50

    CONTEXTUALIZE: bool = True

    EMBEDDING_MODEL: str = "infloat/multilingual-e5-large-instruct"
    EMBEDDING_API_URL: str = "http://infinity-embeddings:8081/v1"
    EMBEDDING_API_KEY: str = "change-me"
    EMBEDDING_DIM: int = 1024
    EMBEDDING_BATCH_SIZE: int = 32

    BASE_MODEL: str = "Qwythos-9B-Claude-Mythos-5-1M-Q8_0.gguf"
    BASE_MODEL_API_URL: str = "http://llamacpp:8080/v1"
    BASE_MODEL_API_KEY: str = "none"
    MAX_TOKENS: int = 8192
    LLM_TEMPERATURE: float = 0.6
    LLM_CONTEXT_TOKENS: int = 16384
    CONTEXTUALIZE_MAX_TOKENS: int = 2048
    CHAT_MODEL_TYPE: str = "default"
    CHAT_ENGINE: str = "simple"
    CONDENSE_ENABLED: bool = True
    CONDENSE_MODEL_TYPE: str = "default"
    CONDENSE_HISTORY_TURNS: int = 6
    CONDENSE_MAX_TOKENS: int = 512
    CONDENSE_MAX_CHARS: int = 512

    POSTGRES_HOST: str = "postgres"
    POSTGRES_PORT: int = 5432
    POSTGRES_DB: str = "varagity"
    POSTGRES_USER: str = "varagity"
    POSTGRES_PASSWORD: str = "change-me"

    ELASTICSEARCH_URL: str = "http://elasticsearch:9200"
    BM25_INDEX_NAME: str = "varagity_contextual_bm25"

    PREFECT_API_URL: str = "http://prefect:4200/api"

    RETRIEVAL_METHOD: str = "hybrid"
    TOP_K: int = 10
    SEMANTIC_WEIGHT: float = 0.8
    BM25_WEIGHT: float = 0.2

    RERANK_ENABLED: bool = False
    RERANK_MODEL: str = "BAAI/bge-reranker-v2-m3"
    RERANK_API_URL: str = "http://infinity-embeddings:8081/v1"
    RERANK_API_KEY: str = "change-me"
    RERANK_TOP_N: int = 5
    RERANK_BASE_METHOD: str = "hybrid"
    RERANK_CANDIDATES: int = 40

    HYDE_ENABLED: bool = True
    HYDE_BASE_METHOD: str = "hybrid"
    HYDE_MODEL_TYPE: str = "default"
    HYDE_MAX_TOKENS: int = 1024
    HYDE_MAX_CHARS: int = 2000

    API_HOST: str = "0.0.0.0"
    API_PORT: int = 8000
    API_CORS_ORIGINS: str = "http://localhost:3000"
    UPLOAD_MAX_MB: int = 50
    UPLOAD_MAX_FILES: int = 500
    UPLOAD_MAX_TOTAL_MB: int = 2048
    UPLOAD_MAX_PATH_DEPTH: int = 12

    METRICS_ENABLED: bool = True
    PROMETHEUS_PORT: int = 9090
    GRAFANA_PORT: int = 3001

    # Graph corpus (spec_graphrag §10) — the kill switch, the GraphRAG message
    # corpus root and its upload cap, the engine and its storage root, the
    # owner/handle identity the iMessage parser consumes, and the query-path
    # knobs (both gate-measured — ADR-017).
    GRAPH_ENABLED: bool = True
    GRAPH_DOCS_PATH: str = "./graph-docs"
    GRAPH_UPLOAD_MAX_MB: int = 4096
    GRAPH_STORAGE_PATH: str = "./graph-data"
    GRAPH_ENGINE: str = "lightrag"  # benchmark-decided default (ADR-017)
    GRAPH_OWNER_ALIASES: str = ""
    GRAPH_HANDLE_NAMES: str = ""
    GRAPH_HANDLE_NAMES_FILE: str = ""
    GRAPH_QUERY_MODE: str = "mix"  # gate-measured default (ADR-017)
    GRAPH_QUERY_PREFIX: bool = True  # gate-measured default (ADR-017)

    @property
    def allowed_extension_set(self) -> frozenset[str]:
        """Parsed ``ALLOWED_EXTENSIONS`` as a normalized set.

        Entries are lowercased, stripped, and guaranteed to start with a dot
        (``"md"`` and ``".md"`` are equivalent in the env value).

        Returns:
            The allowed extensions, e.g. ``frozenset({".pdf", ".txt", ".md"})``.
        """
        extensions = set()
        for raw in self.ALLOWED_EXTENSIONS.split(","):
            ext = raw.strip().lower()
            if not ext:
                continue
            extensions.add(ext if ext.startswith(".") else f".{ext}")
        return frozenset(extensions)

    @property
    def ocr_language_list(self) -> list[str]:
        """Parsed ``OCR_LANGUAGES`` as an ordered, deduplicated list.

        Order is preserved (OCR engines weight the primary language);
        entries are lowercased and stripped.

        Returns:
            The language codes, e.g. ``["en", "de"]``.
        """
        languages: list[str] = []
        for raw in self.OCR_LANGUAGES.split(","):
            code = raw.strip().lower()
            if code and code not in languages:
                languages.append(code)
        return languages

    @property
    def cors_origin_list(self) -> list[str]:
        """Parsed ``API_CORS_ORIGINS`` as an ordered, deduplicated list.

        Entries are stripped; trailing slashes are removed (an origin is
        scheme://host[:port], never a path).

        Returns:
            The allowed origins, e.g. ``["http://localhost:3000"]``.
        """
        origins: list[str] = []
        for raw in self.API_CORS_ORIGINS.split(","):
            origin = raw.strip().rstrip("/")
            if origin and origin not in origins:
                origins.append(origin)
        return origins

    @property
    def graph_owner_alias_list(self) -> list[str]:
        """Parsed ``GRAPH_OWNER_ALIASES`` as an ordered, deduplicated list.

        Order is preserved (the first alias is the owner's display label);
        entries are stripped.

        Returns:
            The owner's aliases, e.g. ``["John", "John Doe"]`` (empty
            when unset).
        """
        aliases: list[str] = []
        for raw in self.GRAPH_OWNER_ALIASES.split(","):
            alias = raw.strip()
            if alias and alias not in aliases:
                aliases.append(alias)
        return aliases

    @property
    def graph_owner_label(self) -> str:
        """The owner's display name for graph messages.

        The first configured alias, or ``"Me"`` when ``GRAPH_OWNER_ALIASES``
        is empty — the label the iMessage parser stamps on ``is_from_me``
        messages.

        Returns:
            The owner label.
        """
        aliases = self.graph_owner_alias_list
        return aliases[0] if aliases else "Me"

    @property
    def graph_handle_name_map(self) -> dict[str, str]:
        """The merged handle→display-name mapping for graph message sources.

        ``GRAPH_HANDLE_NAMES_FILE`` (when set) loads first, then the inline
        ``GRAPH_HANDLE_NAMES`` pairs overlay it — an inline pair overrides
        the file's for the same handle. Both accept ``handle=Name`` pairs
        separated by commas and/or newlines (see
        :func:`_parse_handle_name_pairs`). The file is read on access, not
        at load time, so it only needs to exist where graph parsing runs.

        Returns:
            The mapping, e.g. ``{"+15551234567": "Bob", "jane@x.com":
            "Jane"}`` (empty when neither setting is set).

        Raises:
            ValueError: If the configured file cannot be read, or an entry
                in it lacks an ``=`` separator.
        """
        mapping: dict[str, str] = {}
        if self.GRAPH_HANDLE_NAMES_FILE:
            path = Path(self.GRAPH_HANDLE_NAMES_FILE)
            try:
                content = path.read_text(encoding="utf-8-sig")
            except OSError as exc:
                raise ValueError(
                    f"GRAPH_HANDLE_NAMES_FILE {str(path)!r} cannot be read: {exc}"
                ) from exc
            mapping.update(_parse_handle_name_pairs(content, source=str(path)))
        mapping.update(
            _parse_handle_name_pairs(self.GRAPH_HANDLE_NAMES, source="GRAPH_HANDLE_NAMES")
        )
        return mapping

    @model_validator(mode="after")
    def _validate_api(self) -> "Settings":
        """Reject HTTP API/observability parameters outside their domains (spec_v2 §10).

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``API_PORT``, ``PROMETHEUS_PORT``, or
                ``GRAFANA_PORT`` is not a valid TCP port, or
                ``UPLOAD_MAX_MB`` is not positive.
        """
        for name in ("API_PORT", "PROMETHEUS_PORT", "GRAFANA_PORT"):
            port = getattr(self, name)
            if not 0 < port < 65536:
                raise ValueError(f"{name} must be a valid TCP port (1–65535); got {port}")
        if self.UPLOAD_MAX_MB <= 0:
            raise ValueError(f"UPLOAD_MAX_MB must be positive; got {self.UPLOAD_MAX_MB}")
        return self

    @model_validator(mode="after")
    def _validate_upload_limits(self) -> "Settings":
        """Reject upload batch caps that cannot admit a valid batch (spec_v3 §5.2).

        ``UPLOAD_MAX_MB``'s own positivity lives in :meth:`_validate_api`;
        this validator owns the batch-level caps and their one cross
        constraint: a per-file cap above the whole batch's budget is a
        config bug, not a tighter limit.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``UPLOAD_MAX_FILES``, ``UPLOAD_MAX_TOTAL_MB``, or
                ``UPLOAD_MAX_PATH_DEPTH`` is not positive, or if
                ``UPLOAD_MAX_MB`` exceeds ``UPLOAD_MAX_TOTAL_MB``.
        """
        for name in ("UPLOAD_MAX_FILES", "UPLOAD_MAX_TOTAL_MB", "UPLOAD_MAX_PATH_DEPTH"):
            if getattr(self, name) <= 0:
                raise ValueError(f"{name} must be positive; got {getattr(self, name)}")
        if self.UPLOAD_MAX_MB > self.UPLOAD_MAX_TOTAL_MB:
            raise ValueError(
                f"UPLOAD_MAX_MB ({self.UPLOAD_MAX_MB}) must not exceed "
                f"UPLOAD_MAX_TOTAL_MB ({self.UPLOAD_MAX_TOTAL_MB}) — a per-file cap "
                "above the whole batch's budget is a config bug"
            )
        return self

    @field_validator("OCR_LANGUAGES")
    @classmethod
    def _validate_ocr_languages(cls, value: str) -> str:
        """Reject an OCR language list with no usable entries.

        Args:
            value: The configured ``OCR_LANGUAGES`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If no non-empty code remains after splitting.
        """
        if not any(part.strip() for part in value.split(",")):
            raise ValueError("OCR_LANGUAGES must list at least one language code, e.g. 'en'")
        return value

    @model_validator(mode="after")
    def _validate_pdf_ocr(self) -> "Settings":
        """Reject OCR-fallback trigger parameters outside their domains.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``PDF_OCR_MIN_CHARS`` is negative or
                ``PDF_OCR_TEXTLESS_PAGE_RATIO`` is not within ``[0.0, 1.0]``.
        """
        if self.PDF_OCR_MIN_CHARS < 0:
            raise ValueError(
                f"PDF_OCR_MIN_CHARS must be non-negative; got {self.PDF_OCR_MIN_CHARS}"
            )
        if not 0.0 <= self.PDF_OCR_TEXTLESS_PAGE_RATIO <= 1.0:
            raise ValueError(
                "PDF_OCR_TEXTLESS_PAGE_RATIO must be between 0.0 and 1.0; "
                f"got {self.PDF_OCR_TEXTLESS_PAGE_RATIO}"
            )
        return self

    @model_validator(mode="after")
    def _validate_preview(self) -> "Settings":
        """Reject page-preview parameters outside their domains (ADR-010).

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``PREVIEW_RENDER_WIDTH`` is outside 512–4096,
                ``PREVIEW_MIN_COVERAGE`` is outside ``[0.0, 1.0]``, or
                ``PREVIEW_CONVERT_TIMEOUT_S`` is not positive.
        """
        if not 512 <= self.PREVIEW_RENDER_WIDTH <= 4096:
            raise ValueError(
                "PREVIEW_RENDER_WIDTH must be between 512 and 4096 pixels; "
                f"got {self.PREVIEW_RENDER_WIDTH}"
            )
        if not 0.0 <= self.PREVIEW_MIN_COVERAGE <= 1.0:
            raise ValueError(
                f"PREVIEW_MIN_COVERAGE must be between 0.0 and 1.0; got {self.PREVIEW_MIN_COVERAGE}"
            )
        if self.PREVIEW_CONVERT_TIMEOUT_S <= 0:
            raise ValueError(
                f"PREVIEW_CONVERT_TIMEOUT_S must be positive; got {self.PREVIEW_CONVERT_TIMEOUT_S}"
            )
        return self

    @field_validator("ALLOWED_EXTENSIONS")
    @classmethod
    def _validate_allowed_extensions(cls, value: str) -> str:
        """Reject an extension whitelist with no usable entries.

        Args:
            value: The configured ``ALLOWED_EXTENSIONS`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If no non-empty extension remains after splitting.
        """
        if not any(part.strip() for part in value.split(",")):
            raise ValueError("ALLOWED_EXTENSIONS must list at least one extension, e.g. '.txt,.md'")
        return value

    @model_validator(mode="after")
    def _validate_sizes(self) -> "Settings":
        """Reject size parameters that cannot produce a valid pipeline run.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``CHUNK_SIZE``, ``EMBEDDING_DIM``,
                ``EMBEDDING_BATCH_SIZE``, ``MAX_TOKENS``, ``TOP_K``,
                ``LLM_CONTEXT_TOKENS``, or ``CONTEXTUALIZE_MAX_TOKENS`` is
                not positive, if ``CHUNK_OVERLAP`` is negative, if
                ``CHUNK_OVERLAP`` is not smaller than ``CHUNK_SIZE``, or if
                ``MAX_TOKENS``/``CONTEXTUALIZE_MAX_TOKENS`` is not smaller
                than ``LLM_CONTEXT_TOKENS`` (a generation cap that fills the
                whole window leaves no room for any prompt).
        """
        positives = (
            "CHUNK_SIZE",
            "EMBEDDING_DIM",
            "EMBEDDING_BATCH_SIZE",
            "MAX_TOKENS",
            "TOP_K",
            "LLM_CONTEXT_TOKENS",
            "CONTEXTUALIZE_MAX_TOKENS",
        )
        for name in positives:
            if getattr(self, name) <= 0:
                raise ValueError(f"{name} must be positive; got {getattr(self, name)}")
        if self.CHUNK_OVERLAP < 0:
            raise ValueError(f"CHUNK_OVERLAP must be non-negative; got {self.CHUNK_OVERLAP}")
        if self.CHUNK_OVERLAP >= self.CHUNK_SIZE:
            raise ValueError(
                f"CHUNK_OVERLAP ({self.CHUNK_OVERLAP}) must be smaller than "
                f"CHUNK_SIZE ({self.CHUNK_SIZE})"
            )
        for name in ("MAX_TOKENS", "CONTEXTUALIZE_MAX_TOKENS"):
            if getattr(self, name) >= self.LLM_CONTEXT_TOKENS:
                raise ValueError(
                    f"{name} ({getattr(self, name)}) must be smaller than "
                    f"LLM_CONTEXT_TOKENS ({self.LLM_CONTEXT_TOKENS}) — a generation "
                    "cap that fills the whole context window leaves no room for "
                    "any prompt"
                )
        return self

    @field_validator("RETRIEVAL_METHOD")
    @classmethod
    def _validate_retrieval_method(cls, value: str) -> str:
        """Reject retrieval methods outside the spec §10.1 / spec_v2 §5 / ADR-016 vocabulary.

        Membership in the vocabulary is validated here; registry membership
        is enforced by :func:`varagity.retrieval.get_retriever` at lookup
        time. The vocabulary is hard-coded (importing the retriever
        registry here would be circular — the retrievers read this module
        through their model/store clients); the tuple is regression-tested
        to equal ``varagity.retrieval.RETRIEVER_REGISTRY`` and grows in
        lockstep with it.

        Args:
            value: The configured ``RETRIEVAL_METHOD`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not ``semantic``, ``bm25``,
                ``hybrid``, ``reranked``, or ``hyde``.
        """
        allowed = ("semantic", "bm25", "hybrid", "reranked", "hyde")
        if value not in allowed:
            raise ValueError(f"RETRIEVAL_METHOD must be one of {allowed}; got {value!r}")
        return value

    @model_validator(mode="after")
    def _validate_rerank(self) -> "Settings":
        """Reject rerank parameters that cannot produce a valid rerank stage.

        The cross-method constraints (spec_v2 §5.3): re-ranking *narrows* a
        wider candidate pool, so ``RERANK_TOP_N`` can never exceed the pool,
        and when the ``reranked`` method is selected it can't promise more
        results than ``TOP_K`` nor rerank a pool smaller than ``TOP_K``.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``RERANK_TOP_N`` is not in
                ``(0, RERANK_CANDIDATES]``, if ``RERANK_BASE_METHOD`` is not
                ``semantic``/``bm25``/``hybrid``/``hyde`` (``reranked``
                would recurse), or if ``RETRIEVAL_METHOD == "reranked"``
                with ``RERANK_TOP_N > TOP_K`` or
                ``RERANK_CANDIDATES < TOP_K``.
        """
        if not 0 < self.RERANK_TOP_N <= self.RERANK_CANDIDATES:
            raise ValueError(
                f"RERANK_TOP_N ({self.RERANK_TOP_N}) must be positive and at most "
                f"RERANK_CANDIDATES ({self.RERANK_CANDIDATES})"
            )
        # `hyde` is a valid rerank base (ADR-016 — that composition *is*
        # the HyDE+rerank pairing); the reverse nesting is rejected by
        # `_validate_hyde`.
        allowed_bases = ("semantic", "bm25", "hybrid", "hyde")
        if self.RERANK_BASE_METHOD not in allowed_bases:
            raise ValueError(
                f"RERANK_BASE_METHOD must be one of {allowed_bases} (not 'reranked' — "
                f"no recursion); got {self.RERANK_BASE_METHOD!r}"
            )
        if self.RETRIEVAL_METHOD == "reranked":
            if self.RERANK_TOP_N > self.TOP_K:
                raise ValueError(
                    f"RERANK_TOP_N ({self.RERANK_TOP_N}) must not exceed TOP_K "
                    f"({self.TOP_K}) when RETRIEVAL_METHOD is 'reranked' — rerank "
                    "narrows; it can't invent candidates"
                )
            if self.RERANK_CANDIDATES < self.TOP_K:
                raise ValueError(
                    f"RERANK_CANDIDATES ({self.RERANK_CANDIDATES}) must be at least "
                    f"TOP_K ({self.TOP_K}) when RETRIEVAL_METHOD is 'reranked'"
                )
        return self

    @model_validator(mode="after")
    def _validate_hyde(self) -> "Settings":
        """Reject HyDE parameters outside their domains (ADR-016).

        ``HYDE_BASE_METHOD`` is deliberately narrower than the retriever
        registry: ``bm25`` never consumes a query vector (HyDE over it
        would be a paid LLM call with zero effect), ``reranked`` would
        invert the stacking (the cross-encoder must judge the user's real
        query — compose the pair as ``RERANK_BASE_METHOD=hyde`` instead),
        and ``hyde`` would recurse. ``HYDE_MODEL_TYPE``'s vocabulary is
        hard-coded for the same circular-import reason as
        :meth:`_validate_chat_model_type`.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``HYDE_BASE_METHOD`` is not ``semantic`` or
                ``hybrid``, if ``HYDE_MAX_TOKENS`` or ``HYDE_MAX_CHARS`` is
                not positive, or if ``HYDE_MODEL_TYPE`` is not ``default``,
                ``reasoning``, or ``tool``.
        """
        allowed_bases = ("semantic", "hybrid")
        if self.HYDE_BASE_METHOD not in allowed_bases:
            raise ValueError(
                f"HYDE_BASE_METHOD must be one of {allowed_bases} ('bm25' ignores query "
                "vectors; 'reranked' must compose hyde via RERANK_BASE_METHOD=hyde, not "
                f"the reverse); got {self.HYDE_BASE_METHOD!r}"
            )
        for name in ("HYDE_MAX_TOKENS", "HYDE_MAX_CHARS"):
            if getattr(self, name) <= 0:
                raise ValueError(f"{name} must be positive; got {getattr(self, name)}")
        allowed_model_types = ("default", "reasoning", "tool")
        if self.HYDE_MODEL_TYPE not in allowed_model_types:
            raise ValueError(
                f"HYDE_MODEL_TYPE must be one of {allowed_model_types}; "
                f"got {self.HYDE_MODEL_TYPE!r}"
            )
        return self

    @model_validator(mode="after")
    def _validate_fusion_weights(self) -> "Settings":
        """Reject hybrid rank-fusion weights that don't form a convex blend.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If either weight is negative, or if
                ``SEMANTIC_WEIGHT + BM25_WEIGHT`` is not 1.0 (spec §6; checked
                with a small tolerance because the values arrive as decimal
                strings, e.g. ``0.7 + 0.3`` is not exactly ``1.0`` in binary
                floating point).
        """
        for name in ("SEMANTIC_WEIGHT", "BM25_WEIGHT"):
            if getattr(self, name) < 0:
                raise ValueError(f"{name} must be non-negative; got {getattr(self, name)}")
        total = self.SEMANTIC_WEIGHT + self.BM25_WEIGHT
        if abs(total - 1.0) > 1e-9:
            raise ValueError(
                f"SEMANTIC_WEIGHT ({self.SEMANTIC_WEIGHT}) + BM25_WEIGHT ({self.BM25_WEIGHT}) "
                f"must sum to 1.0; got {total}"
            )
        return self

    @field_validator("CHAT_MODEL_TYPE")
    @classmethod
    def _validate_chat_model_type(cls, value: str) -> str:
        """Reject chat model types outside the LLM aliases.

        The vocabulary is hard-coded (mirroring ``RETRIEVAL_METHOD``) because
        importing the model registry here would be circular — the registry's
        clients read this module. ``varagity.models.registry.LLM_MODEL_TYPES``
        is the same tuple, regression-tested to match.

        Args:
            value: The configured ``CHAT_MODEL_TYPE`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not ``default``, ``reasoning``, or
                ``tool`` (``embedding``/``rerank`` are not chat models).
        """
        allowed = ("default", "reasoning", "tool")
        if value not in allowed:
            raise ValueError(f"CHAT_MODEL_TYPE must be one of {allowed}; got {value!r}")
        return value

    @field_validator("CHAT_ENGINE")
    @classmethod
    def _validate_chat_engine(cls, value: str) -> str:
        """Reject chat engines outside the spec_v3 §4.2 vocabulary.

        The vocabulary is hard-coded (mirroring ``CHAT_MODEL_TYPE``) because
        importing the engine registry here would be circular —
        ``varagity/chat/`` reads this module through its model clients. The
        tuple is regression-tested to equal
        ``varagity.chat.CHAT_ENGINE_REGISTRY`` and grows in lockstep with
        it.

        Args:
            value: The configured ``CHAT_ENGINE`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not a registered engine name.
        """
        allowed = ("simple", "condense_context")
        if value not in allowed:
            raise ValueError(f"CHAT_ENGINE must be one of {allowed}; got {value!r}")
        return value

    @model_validator(mode="after")
    def _validate_condense(self) -> "Settings":
        """Reject condense-stage parameters outside their domains (spec_v3 §4.6).

        ``CONDENSE_MODEL_TYPE``'s vocabulary is hard-coded for the same
        circular-import reason as :meth:`_validate_chat_model_type` — the
        model registry's clients read this module.

        Returns:
            The validated settings instance.

        Raises:
            ValueError: If ``CONDENSE_HISTORY_TURNS`` is negative, if
                ``CONDENSE_MAX_TOKENS`` or ``CONDENSE_MAX_CHARS`` is not
                positive, or if ``CONDENSE_MODEL_TYPE`` is not ``default``,
                ``reasoning``, or ``tool``.
        """
        if self.CONDENSE_HISTORY_TURNS < 0:
            raise ValueError(
                f"CONDENSE_HISTORY_TURNS must be non-negative (0 disables history); "
                f"got {self.CONDENSE_HISTORY_TURNS}"
            )
        for name in ("CONDENSE_MAX_TOKENS", "CONDENSE_MAX_CHARS"):
            if getattr(self, name) <= 0:
                raise ValueError(f"{name} must be positive; got {getattr(self, name)}")
        allowed = ("default", "reasoning", "tool")
        if self.CONDENSE_MODEL_TYPE not in allowed:
            raise ValueError(
                f"CONDENSE_MODEL_TYPE must be one of {allowed}; got {self.CONDENSE_MODEL_TYPE!r}"
            )
        return self

    @field_validator("LLM_TEMPERATURE")
    @classmethod
    def _validate_llm_temperature(cls, value: float) -> float:
        """Reject sampling temperatures outside the OpenAI-compatible range.

        Args:
            value: The configured ``LLM_TEMPERATURE`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not within ``[0.0, 2.0]``.
        """
        if not 0.0 <= value <= 2.0:
            raise ValueError(f"LLM_TEMPERATURE must be between 0.0 and 2.0; got {value}")
        return value

    @field_validator("DEFAULT_VERBOSE")
    @classmethod
    def _validate_default_verbose(cls, value: int) -> int:
        """Reject verbosity defaults outside the supported levels.

        Args:
            value: The configured ``DEFAULT_VERBOSE`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not 0, 1, or 2.
        """
        if value not in (0, 1, 2):
            raise ValueError(f"DEFAULT_VERBOSE must be 0 (off), 1 (low), or 2 (high); got {value}")
        return value

    @field_validator("GRAPH_ENGINE")
    @classmethod
    def _validate_graph_engine(cls, value: str) -> str:
        """Reject graph engines outside the spec_graphrag §5.2 vocabulary.

        The vocabulary is hard-coded (mirroring :meth:`_validate_chat_engine`)
        because importing the engine registry here would be circular —
        ``varagity/graph/`` reads this module through its model clients and
        its own adapter. The tuple is regression-tested to equal
        ``varagity.graph.engines.GRAPH_ENGINE_REGISTRY`` and grows in lockstep
        with it.

        Args:
            value: The configured ``GRAPH_ENGINE`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not a registered engine name.
        """
        allowed = ("lightrag",)
        if value not in allowed:
            raise ValueError(f"GRAPH_ENGINE must be one of {allowed}; got {value!r}")
        return value

    @field_validator("GRAPH_QUERY_MODE")
    @classmethod
    def _validate_graph_query_mode(cls, value: str) -> str:
        """Reject graph query modes the engine would not accept.

        Hard-coded for the same circular-import reason as
        :meth:`_validate_graph_engine` — the adapter reads this module — and
        pinned by a regression test to
        :data:`varagity.graph.engines.lightrag.QUERY_MODES`, which is the
        engine's own vocabulary. Catching a typo here matters more than
        usual: the engine treats an unknown mode as its own default and
        answers anyway, so a misconfigured mode would be invisible.

        Args:
            value: The configured ``GRAPH_QUERY_MODE`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If ``value`` is not one of the engine's modes.
        """
        allowed = ("local", "global", "hybrid", "naive", "mix")
        if value not in allowed:
            raise ValueError(f"GRAPH_QUERY_MODE must be one of {allowed}; got {value!r}")
        return value

    @field_validator("GRAPH_HANDLE_NAMES")
    @classmethod
    def _validate_graph_handle_names(cls, value: str) -> str:
        """Reject ``GRAPH_HANDLE_NAMES`` entries that are not ``handle=Name`` pairs.

        Delegates to :func:`_parse_handle_name_pairs` so a typo surfaces at
        load time, not as a silently mis-parsed handle. The file variant
        (``GRAPH_HANDLE_NAMES_FILE``) is deliberately *not* validated here —
        the file may not exist where settings load (e.g. in a container);
        :attr:`graph_handle_name_map` validates it on access instead.

        Args:
            value: The configured ``GRAPH_HANDLE_NAMES`` value.

        Returns:
            The validated value, unchanged.

        Raises:
            ValueError: If any non-empty entry lacks a ``=`` separator.
        """
        _parse_handle_name_pairs(value, source="GRAPH_HANDLE_NAMES")
        return value

allowed_extension_set property

Parsed ALLOWED_EXTENSIONS as a normalized set.

Entries are lowercased, stripped, and guaranteed to start with a dot ("md" and ".md" are equivalent in the env value).

Returns:

Type Description
frozenset[str]

The allowed extensions, e.g. frozenset({".pdf", ".txt", ".md"}).

cors_origin_list property

Parsed API_CORS_ORIGINS as an ordered, deduplicated list.

Entries are stripped; trailing slashes are removed (an origin is scheme://host[:port], never a path).

Returns:

Type Description
list[str]

The allowed origins, e.g. ["http://localhost:3000"].

graph_handle_name_map property

The merged handle→display-name mapping for graph message sources.

GRAPH_HANDLE_NAMES_FILE (when set) loads first, then the inline GRAPH_HANDLE_NAMES pairs overlay it — an inline pair overrides the file's for the same handle. Both accept handle=Name pairs separated by commas and/or newlines (see :func:_parse_handle_name_pairs). The file is read on access, not at load time, so it only needs to exist where graph parsing runs.

Returns:

Type Description
dict[str, str]

The mapping, e.g. ``{"+15551234567": "Bob", "jane@x.com":

dict[str, str]

"Jane"}`` (empty when neither setting is set).

Raises:

Type Description
ValueError

If the configured file cannot be read, or an entry in it lacks an = separator.

graph_owner_alias_list property

Parsed GRAPH_OWNER_ALIASES as an ordered, deduplicated list.

Order is preserved (the first alias is the owner's display label); entries are stripped.

Returns:

Type Description
list[str]

The owner's aliases, e.g. ["John", "John Doe"] (empty

list[str]

when unset).

graph_owner_label property

The owner's display name for graph messages.

The first configured alias, or "Me" when GRAPH_OWNER_ALIASES is empty — the label the iMessage parser stamps on is_from_me messages.

Returns:

Type Description
str

The owner label.

ocr_language_list property

Parsed OCR_LANGUAGES as an ordered, deduplicated list.

Order is preserved (OCR engines weight the primary language); entries are lowercased and stripped.

Returns:

Type Description
list[str]

The language codes, e.g. ["en", "de"].

get_settings() cached

Return the process-wide :class:Settings instance.

The instance is created on first call and cached. Tests that need a fresh load (e.g. after patching the environment) call get_settings.cache_clear() or construct :class:Settings directly.

Returns:

Type Description
Settings

The cached application settings.

Source code in varagity/config.py
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
@lru_cache(maxsize=1)
def get_settings() -> Settings:
    """Return the process-wide :class:`Settings` instance.

    The instance is created on first call and cached. Tests that need a
    fresh load (e.g. after patching the environment) call
    ``get_settings.cache_clear()`` or construct :class:`Settings` directly.

    Returns:
        The cached application settings.
    """
    return Settings()

varagity.logging_setup

Central logging configuration.

Installs a :class:rich.logging.RichHandler on the root logger, once, at application startup. Modules obtain their own logger with logging.getLogger(__name__) and never configure handlers themselves — this module is the only place logging is configured (spec §14, channel 2).

setup_logging(level='INFO')

Configure root logging with rich console output.

Safe to call more than once: the root handler set is replaced on each call, so repeated setup (tests, re-entrant CLIs) never stacks duplicate handlers.

Parameters:

Name Type Description Default
level str

Root log level name, e.g. "DEBUG" or "INFO" (case-insensitive).

'INFO'

Raises:

Type Description
ValueError

If level is not a recognized logging level name.

Source code in varagity/logging_setup.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def setup_logging(level: str = "INFO") -> None:
    """Configure root logging with rich console output.

    Safe to call more than once: the root handler set is replaced on each
    call, so repeated setup (tests, re-entrant CLIs) never stacks duplicate
    handlers.

    Args:
        level: Root log level name, e.g. ``"DEBUG"`` or ``"INFO"``
            (case-insensitive).

    Raises:
        ValueError: If ``level`` is not a recognized logging level name.
    """
    logging.basicConfig(
        level=level.upper(),
        format="%(message)s",
        datefmt="[%X]",
        handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
        force=True,
    )
    for name in _NOISY_LOGGERS:
        logging.getLogger(name).setLevel(logging.WARNING)

varagity.tokens

Approximate token counting.

Uses tiktoken's cl100k_base encoding as a documented approximation (plan decision #8): the e5 embedding model actually tokenizes with an XLM-RoBERTa SentencePiece vocabulary, but a portable approximation is enough for the two consumers here — the n_tokens provenance field on :class:~varagity.stores.records.ChunkRecord and the near-512-token ingest warning in :class:~varagity.models.embeddings.EmbeddingsClient.

It is not the served chat model's count. That model gives every digit its own token where cl100k packs up to three, so digit-dense text runs far over its cl100k estimate — 1.45× on a timestamp-and-coordinate export, 3× on bare digit runs. Anything that must fit the llama.cpp window exactly asks the server instead (:meth:varagity.models.llm.LLMClient.count_served_tokens).

tiktoken downloads its BPE ranks file on first use (cached afterwards). If that download fails (offline host), counting falls back to a chars/4 estimate with a one-time warning instead of failing ingestion.

count_tokens(text)

Count tokens in text (approximate; see module docstring).

Parameters:

Name Type Description Default
text str

The text to count.

required

Returns:

Type Description
int

The cl100k_base token count, or a chars/4 estimate if the encoding

int

could not be loaded.

Source code in varagity/tokens.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def count_tokens(text: str) -> int:
    """Count tokens in ``text`` (approximate; see module docstring).

    Args:
        text: The text to count.

    Returns:
        The cl100k_base token count, or a chars/4 estimate if the encoding
        could not be loaded.
    """
    encoding = _encoding()
    if encoding is None:
        return len(text) // _FALLBACK_CHARS_PER_TOKEN
    return len(encoding.encode(text))

truncate_to_tokens(text, max_tokens)

Truncate text to at most max_tokens tokens (approximate).

The contextualizer's cheap first cut of an oversized document preamble; the served tokenizer then has the last word on whether the cut fits (:func:varagity.context.contextual.fit_document_preamble).

Parameters:

Name Type Description Default
text str

The text to truncate.

required
max_tokens int

Maximum tokens to keep (must be non-negative).

required

Returns:

Type Description
str

text unchanged when it fits, otherwise its first max_tokens

str

tokens decoded back to a string (chars/4 estimate if the encoding

str

could not be loaded).

Raises:

Type Description
ValueError

If max_tokens is negative.

Source code in varagity/tokens.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def truncate_to_tokens(text: str, max_tokens: int) -> str:
    """Truncate ``text`` to at most ``max_tokens`` tokens (approximate).

    The contextualizer's cheap first cut of an oversized document preamble;
    the served tokenizer then has the last word on whether the cut fits
    (:func:`varagity.context.contextual.fit_document_preamble`).

    Args:
        text: The text to truncate.
        max_tokens: Maximum tokens to keep (must be non-negative).

    Returns:
        ``text`` unchanged when it fits, otherwise its first ``max_tokens``
        tokens decoded back to a string (chars/4 estimate if the encoding
        could not be loaded).

    Raises:
        ValueError: If ``max_tokens`` is negative.
    """
    if max_tokens < 0:
        raise ValueError(f"max_tokens must be non-negative; got {max_tokens}")
    encoding = _encoding()
    if encoding is None:
        return text[: max_tokens * _FALLBACK_CHARS_PER_TOKEN]
    tokens = encoding.encode(text)
    if len(tokens) <= max_tokens:
        return text
    return encoding.decode(tokens[:max_tokens])