services/semif-serve is a FastAPI wrapper around SemIf's direct and shared torch scorers (SemIf-OpenJev @ 23cf1f39, MIT). Upstream ships only a batch CLI. The wrapper loads the pinned Qwen3.5-4B (851bf6e8, BF16) once from the offline HF cache and returns SemIf's result dicts unchanged, with an optional per-workload temperature-calibrated view. Contract: semif-serve.contract.md. Built with a short contract, TDD (39 tests, fake engine and fake torch, no GPU) and a heid bug-hunt panel (pending). On the card: - torch 2.10.0+cu128 with sm_120 kernels, which is SemIf's own stack; - a hard 12 GiB VRAM cap. Two defects surfaced only on the card, and each fix is covered by a test: - 0.1.1: an OOM raised as a chained exception kept the failed request's tensors alive (11.9 GiB after the 503). It is now raised unchained, after gc. - 0.1.2: a large request left 12.6 GB reserved on the shared card. After each call, reserved memory over the baseline + 512 MiB is now released. Acceptance against SemIf's committed torch predictions (authored144): - 142/144 same top choice; both misses are exact bf16 ties; - 144/144 identical prompt hashes; - deterministic A-vs-A; - negative control 14/144; - shared vs direct 72/72. 21 binary criteria over one state take 159 ms. The shared-mode capacity table under the cap is in stacks/semif/README.md. The Dockerfile installs dependencies from a manifest with the project version blanked, so a version bump reuses the ~4 GB torch layer. Verified: 41 s rebuild, dependency layer CACHED. DNS: semif.fv.internal. Token: vault semif/api-token.
Fleet internal DNS — *.internal
Names for fleet hosts so nobody has to remember addresses. Built 2026-08-19 because IPv6 makes memorising them hopeless — and, more to the point, because v6 addresses are derived rather than assigned, so they cannot be reliably memorised or written down once and trusted.
dns/internal.yaml the source of truth — hosts, sites, aliases
scripts/dns-sync.py reconciles the resolvers against it
Adding a name
Edit dns/internal.yaml, then:
scripts/dns-sync.py --dry-run # see the diff
scripts/dns-sync.py # apply, with a prompt
That is the whole workflow. It is deliberately the same shape as
deploy-stack.sh: a file in git is the intent, the running system is derived
state, and you see a diff before anything changes.
Naming
<host>.<site>.internal, sites ana (Anaheim colo), esh (home lab),
nh3 (office).
.internal is ICANN-reserved for private use, which is why it is used here
rather than .local (reserved for mDNS — the old searxng.pfi.local was a
standards collision that happened to work) or an invented TLD that could later
collide with a real one.
Every name is published to every resolver. The site label says where a host
is, not which resolver knows about it — ana-docker.ana.internal resolves
from ESH and NH3 too.
Irvine is not a fourth zone: irv-ml1 is reachable only through NH3's
WireGuard tunnel and numbered out of NH3's 10.100.79.0/24, so it lives under
nh3. Worth revisiting if Irvine ever becomes a site in its own right.
The resolvers
| site | resolver | API port |
|---|---|---|
| ana | ana-docker 10.250.50.70 |
8053 |
| esh | esh-docker-vm 10.0.50.45 |
8080 |
| nh3 | nh3-docker 10.100.50.40 |
8080 |
ana is the odd one out — :8080 and :3000 were already taken on that host —
so the port is carried per-site in internal.yaml rather than assumed by the
script.
The colo resolver (stacks/adguard-ana/) was stood up as part of this work;
before it, colo hosts resolved straight against 1.1.1.1 and the site had no
way to answer for internal names. ESH and NH3 run older, unmanaged compose
files, left alone on purpose — adopting three live resolvers into this repo
while also introducing a new naming system is two risky changes at once.
Two properties worth not breaking
Authority is scoped to the zone, not the resolver. Only rewrites ending in
.internal are managed. The ESH resolver carries hand-made esteban.net
entries that predate this system; the sync reads them, ignores them, and leaves
them alone. If this ever grows to manage another zone, that scoping is the
thing to be careful with — resolver-wide authority would silently delete
somebody else's work.
Within the zone it is authoritative. Names added by hand in the AdGuard UI will be deleted by the next sync. That is the point: one place to look.
Credential
scripts/dns-sync.py authenticates as a dedicated infra-ops AdGuard user,
not as the operator's account, and pulls the password from the vault:
secret get nh3-dev/adguard-infra-ops-password
⚠️ The vault appends a trailing newline on read. The script strips it, because
a password carrying a stray \n fails auth in a way that looks exactly like a
wrong password.
The existing lkraven AdGuard user was left untouched. Config backups from
before the user was added are on each resolver as
AdGuardHome.yaml.bak-preinfraops-*.
⚠️ IPv6 — the reason this exists, and still the unfinished half
The v6: column is empty and that is correct as of 2026-08-19: no fleet host
has a global IPv6 address yet. ESH's /56 is live only on esh-cameras,
NH3's LANs are back to ipv6_interface_type: none, the colo has no v6 at all.
When v6 arrives, do not paste in whatever ip -6 addr shows. SLAAC gives
hosts either EUI-64 addresses (MAC-coupled) or privacy-extension ones (which
rotate), and UniFi has no v6 equivalent of a DHCP reservation. An address only
belongs in this file once it has been pinned statically on the host itself.
A record that silently stops matching reality is worse than no record — the
name keeps resolving and starts lying.
The suggested convention when that happens: give each server a static address
out of its site's /64 whose low-order bits echo the v4 host octet
(esh-docker-vm at …::45), so the addresses are both declarable and
semi-memorable.
Not migrated: matrix.pfi.local
searxng.pfi.local moved to searxng.ana.internal (both names still route,
so nothing breaks mid-migration; drop the fallback Host() in
stacks/searxng/compose.yaml once the Traefik log shows the old one unused).
matrix.pfi.local was deliberately left alone. A Matrix server_name is
baked into every user ID, room ID and signing key, and federation identity is
derived from it — renaming it is not a DNS change, it is rebuilding the
homeserver's identity and invalidating its history. It stays on .local.
Still open
Colo hosts still point at 1.1.1.1, so they do not yet use the new resolver
— they only get answers if something asks it directly. Repointing a whole
site's DNS is a bigger change than standing the service up, so it is a separate
operator-approved step.