<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Jubaleth — Notebook</title>
    <link>https://jubaleth.wtf/notes/</link>
    <description>Small, durable observations from building and operating real systems—too focused for a full article, but useful enough to preserve.</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <atom:link href="https://jubaleth.wtf/notes/index.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Wed, 02 Sep 2026 00:00:00 &#43;0000</lastBuildDate><item>
      <title>The 731G Corpus Occupied 154G</title>
      <link>https://jubaleth.wtf/notes/2026-09/the-731g-corpus-occupied-154g/</link>
      <pubDate>Wed, 02 Sep 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-09/the-731g-corpus-occupied-154g/</guid>
      <category>AI/ML</category>
      <category>SkyeEngine</category>
      <category>Storage</category>
      <category>Btrfs</category>
      <category>ZFS</category>
      <category>Proxmox</category>
      <category>GLaDOS</category>
      <description>Moving the corpus from ZFS with LZ4 to Btrfs with zstd:6 reduced its footprint from 276.5G to 153.8G and gave the VM pool its headroom back.</description>
      <content:encoded>&lt;aside class=&#34;ip-disclaimer&#34; role=&#34;note&#34; aria-label=&#34;GLaDOS research status and non-affiliation notice&#34;&gt;
  &lt;div class=&#34;ip-disclaimer__grid&#34; aria-hidden=&#34;true&#34;&gt;&lt;/div&gt;
  &lt;svg class=&#34;ip-disclaimer__trace&#34; viewBox=&#34;0 0 1000 300&#34; preserveAspectRatio=&#34;none&#34; aria-hidden=&#34;true&#34; focusable=&#34;false&#34;&gt;
    &lt;rect class=&#34;ip-disclaimer__trace-halo&#34; x=&#34;1&#34; y=&#34;1&#34; width=&#34;998&#34; height=&#34;298&#34; rx=&#34;6&#34; pathLength=&#34;100&#34;&gt;&lt;/rect&gt;
    &lt;rect class=&#34;ip-disclaimer__trace-core&#34; x=&#34;1&#34; y=&#34;1&#34; width=&#34;998&#34; height=&#34;298&#34; rx=&#34;6&#34; pathLength=&#34;100&#34;&gt;&lt;/rect&gt;
  &lt;/svg&gt;
  &lt;header class=&#34;ip-disclaimer__head&#34;&gt;
    &lt;span class=&#34;ip-disclaimer__icon&#34; aria-hidden=&#34;true&#34;&gt;&lt;i data-lucide=&#34;shield-alert&#34;&gt;&lt;/i&gt;&lt;/span&gt;
    &lt;div class=&#34;ip-disclaimer__heading&#34;&gt;
      &lt;span&gt;// IP &amp;amp; AVAILABILITY NOTICE&lt;/span&gt;
      &lt;strong&gt;INDEPENDENT FAN PROJECT&lt;/strong&gt;
    &lt;/div&gt;
    &lt;div class=&#34;ip-disclaimer__affiliation&#34;&gt;&lt;span aria-hidden=&#34;true&#34;&gt;&lt;/span&gt;NOT AFFILIATED WITH VALVE&lt;/div&gt;
  &lt;/header&gt;
  &lt;div class=&#34;ip-disclaimer__body&#34;&gt;
    &lt;section class=&#34;ip-disclaimer__item&#34;&gt;
      &lt;span class=&#34;ip-disclaimer__index&#34; aria-hidden=&#34;true&#34;&gt;01&lt;/span&gt;
      &lt;div&gt;
        &lt;span class=&#34;ip-disclaimer__label&#34;&gt;VALVE INTELLECTUAL PROPERTY&lt;/span&gt;
        &lt;p&gt;GLaDOS, Portal, Aperture Science, and related names, characters, logos and game content are intellectual property of Valve Corporation. This independent research project, its models, and this website are not affiliated with, endorsed by, sponsored by, or associated with Valve Corporation. No ownership of Valve&#39;s intellectual property is claimed.&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;section class=&#34;ip-disclaimer__item&#34;&gt;
      &lt;span class=&#34;ip-disclaimer__index&#34; aria-hidden=&#34;true&#34;&gt;02&lt;/span&gt;
      &lt;div&gt;
        &lt;span class=&#34;ip-disclaimer__label&#34;&gt;PRIVATE RESEARCH STATUS&lt;/span&gt;
        &lt;p&gt;GLaDOS is private research. Its source, training data, models, checkpoints and weights have never been released or distributed, and discussion of the project does not constitute an offer or intention to release them.&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
  &lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;The GLaDOS 3.0 corpus had become too large to share a hypervisor&amp;rsquo;s ZFS pool politely. It was competing with VM and container storage, had pushed the pool to roughly 90% capacity and was still growing.
&lt;/p&gt;
&lt;p&gt;An earlier ext4 volume had already demonstrated that reserving nearly the whole volume group without compression was not a durable answer. This time I removed an unused thin pool and created a smaller, dedicated Btrfs logical volume for the corpus, leaving meaningful unallocated headroom for the host.&lt;/p&gt;
&lt;p&gt;The volume uses &lt;code&gt;zstd:6&lt;/code&gt;, &lt;code&gt;noatime&lt;/code&gt;, &lt;code&gt;nofail&lt;/code&gt; and a bounded systemd device timeout. The failure behaviour matters on a hypervisor: a missing data volume should not put the whole node into emergency mode or make boot wait indefinitely. &lt;code&gt;noatime&lt;/code&gt; avoids turning corpus reads into writes, and the final &lt;code&gt;fstab&lt;/code&gt; field remains &lt;code&gt;0&lt;/code&gt; because Btrfs does not use a traditional boot-time &lt;code&gt;fsck&lt;/code&gt; pass. After changing &lt;code&gt;fstab&lt;/code&gt;, systemd still needs a daemon reload before the generated mount unit is tested.&lt;/p&gt;
&lt;p&gt;The volume is bind-mounted into an unprivileged development container and deliberately excluded from the container backup. Bind mounts are a root-only administrative action in Proxmox, and host-side ownership has to account for the container&amp;rsquo;s shifted ID mapping. The node-local mount also means the container is deliberately non-HA; the corpus workspace has a different availability and backup contract from replicated VM storage.&lt;/p&gt;
&lt;p&gt;After moving the workload, the ZFS pool fell from about 90% to 47.4%. The corpus itself measured 731G logically with &lt;code&gt;du&lt;/code&gt;, but occupied 153.8G physically: a 4.75:1 ratio.&lt;/p&gt;
&lt;p&gt;The important measurement correction is that &lt;code&gt;df&lt;/code&gt; does not lie about Btrfs compression. Both &lt;code&gt;df&lt;/code&gt; and &lt;code&gt;btrfs filesystem usage&lt;/code&gt; report physical, post-compression space. &lt;code&gt;du&lt;/code&gt; supplies the uncompressed logical size. The useful compression check is therefore &lt;code&gt;compsize&lt;/code&gt;, or the relationship between &lt;code&gt;du&lt;/code&gt; and &lt;code&gt;df&lt;/code&gt;, rather than treating disagreement between them as a filesystem accounting error.&lt;/p&gt;
&lt;p&gt;The move reduced the measured footprint from 276.54G on ZFS to 153.8G on Btrfs: about 122.7G, or 44%. That is a compression-setting result rather than a filesystem contest. The ZFS dataset inherits &lt;code&gt;compression=lz4&lt;/code&gt;, reports a 2.87× compression ratio and uses the default 128K record size. The Btrfs volume uses &lt;code&gt;zstd:6&lt;/code&gt; and reaches 4.75:1 on the same highly compressible JSONL. A fair filesystem comparison would put ZFS on &lt;code&gt;zstd-6&lt;/code&gt; too; with a larger record size, it might even edge ahead by seeing repeated boilerplate across a wider window. The result here shows the difference between LZ4 and a stronger Zstandard level on this data, not a uniquely clever filesystem.&lt;/p&gt;
&lt;p&gt;VDO was the wrong alternative for the same reason. Its block-layer deduplication and compression would add a pinned UDS index and compress isolated 4K blocks with LZ4. That is useful for some virtual-machine workloads, but a poor match for large text shards which already respond exceptionally well to filesystem-level Zstandard.&lt;/p&gt;
&lt;p&gt;The move also clarified two ZFS numbers I had been reading too dramatically. &lt;code&gt;FRAG&lt;/code&gt; describes free-space fragmentation, not fragmented files, and freeing blocks does not make it fall immediately. There is no online &lt;code&gt;zpool defrag&lt;/code&gt;; rewriting into a new dataset is the eventual remedy if fragmentation becomes a real problem. The gap between the pool&amp;rsquo;s raw and usable capacity was also ordinary 1/32 slop space, not missing storage. More importantly, dropping below roughly 80% capacity moved the allocator away from its slower best-fit path. For this pool, restoring headroom matters more to write latency than making the fragmentation number look nicer.&lt;/p&gt;
&lt;p&gt;The Btrfs failure mode worth watching is metadata exhaustion. A many-small-files workload can reach &lt;code&gt;ENOSPC&lt;/code&gt; while data space still appears plentiful because its metadata block group is full. This corpus is sharded rather than stored as one file per conversation, and its metadata allocation remains healthy rather than merely large.&lt;/p&gt;
&lt;p&gt;There are two remaining compromises. The current tree is still a flat filesystem instead of a Btrfs subvolume, so adding snapshots later would require a real copy across a subvolume boundary. The cheaper plan is to create the next corpus tree as a subvolume from the beginning. The volume also uses &lt;code&gt;data,single&lt;/code&gt;: checksums can detect corruption, but cannot repair it from another local copy. That is acceptable only because the local disk is working storage rather than the sole copy.&lt;/p&gt;
&lt;p&gt;The remaining checks are operational. I still need to confirm that the monthly scrub timer is actually firing, because checksums only help when something reads them. I also need to measure the ratio again when the next corpus lands. More binary or pre-tokenized data will compress less, and &lt;code&gt;zstd:6&lt;/code&gt; will stop looking free. A future scratch volume for output deleted an hour later should probably use &lt;code&gt;zstd:1&lt;/code&gt;, or no compression at all, rather than spending CPU to optimize temporary bytes.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The LTE Modem Gave Every IPv6 Prefix an Infinite Lifetime</title>
      <link>https://jubaleth.wtf/notes/2026-09/the-lte-modem-gave-every-ipv6-prefix-an-infinite-lifetime/</link>
      <pubDate>Tue, 01 Sep 2026 10:05:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-09/the-lte-modem-gave-every-ipv6-prefix-an-infinite-lifetime/</guid>
      <category>Networking</category>
      <category>VyOS</category>
      <category>IPv6</category>
      <category>LTE</category>
      <category>Failover</category>
      <category>Reliability</category>
      <description>Repeated malformed router advertisements filled an LTE interface with permanent SLAAC addresses until the current routed prefix no longer had a reliable source.</description>
      <content:encoded>&lt;p&gt;IPv6 disappeared from r3&amp;rsquo;s LTE backup while the modem was still sending router advertisements. The interface had accumulated ten global SLAAC addresses. They all shared the same interface identifier, but most of their network prefixes were malformed and outside the LTE provider&amp;rsquo;s allocation.&lt;/p&gt;
&lt;p&gt;Prefix rotation itself was expected. The modem supplies a new autonomous &lt;code&gt;/64&lt;/code&gt; through a Prefix Information Option, and Linux constructs an address from it. The problem was the lifetime attached to each option: valid forever, preferred forever.&lt;/p&gt;
&lt;p&gt;Linux was doing exactly what it had been told. Without a later advertisement withdrawing an old prefix or reducing its lifetime, every generated address remained on the interface. Repeated rotations gradually approached the kernel&amp;rsquo;s per-interface IPv6 address limit. Once that limit was reached, the next legitimate prefix could be advertised without producing a usable address. Even before then, selecting the first global address was no longer a safe way to choose a source for an isolated LTE probe.&lt;/p&gt;
&lt;p&gt;I cannot fix the modem, and disabling router advertisements on r3 would also discard the LTE default route and its learned link-local gateway. Blindly flushing global addresses was not safe either. During a broken handover, an older provider prefix might still be the only one that was actually routed.&lt;/p&gt;
&lt;p&gt;The failover supervisor now reconciles the LTE advertisement before it makes route decisions or probes the backup. On every run it actively solicits one RA and requires exactly one advertising router and one autonomous &lt;code&gt;/64&lt;/code&gt;. The router must match the kernel-accepted RA default, and the prefix must sit inside the provider aggregate already established for this LTE service. An ambiguous, missing or out-of-allocation advertisement fails closed without deleting anything.&lt;/p&gt;
&lt;p&gt;After accepting the advertisement, the supervisor considers only global addresses installed as &lt;code&gt;proto kernel_ra&lt;/code&gt;. It waits for duplicate-address detection to finish and requires exactly one usable address from the current &lt;code&gt;/64&lt;/code&gt;. Static addresses and addresses on other interfaces are outside the cleanup scope.&lt;/p&gt;
&lt;p&gt;Malformed addresses outside the provider aggregate can then be removed, with a hard upper bound on how many deletions one run may perform. Older addresses inside the provider aggregate are treated more carefully: they remain until the newly advertised source passes an IPv6 probe through an isolated LTE policy-routing table. Only after that proof does the supervisor prune the previous provider prefixes. The probe source is the reconciled current address, not whichever global address the kernel happens to list first.&lt;/p&gt;
&lt;p&gt;The regression tests cover malformed prefixes, multiple PIOs, a router mismatch, tentative addresses, excessive cleanup, preservation of static addresses, and the two-stage handover from an older provider prefix. Fixed-cardinality metrics report reconciliation success, remaining SLAAC address count and removals without putting prefixes into labels.&lt;/p&gt;
&lt;p&gt;After the change was merged and deployed, the accumulated RA state converged and LTE IPv6 recovered. The modem is still an unreliable source of lifecycle information; r3 now treats each advertisement as input to validate and reconcile rather than durable truth.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The Probe Timed Out After It Had Already Succeeded</title>
      <link>https://jubaleth.wtf/notes/2026-08/the-probe-timed-out-after-it-had-already-succeeded/</link>
      <pubDate>Fri, 28 Aug 2026 11:13:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/the-probe-timed-out-after-it-had-already-succeeded/</guid>
      <category>Networking</category>
      <category>VyOS</category>
      <category>Failover</category>
      <category>Monitoring</category>
      <category>Reliability</category>
      <description>A three-second wrapper killed partially successful ICMP probes, and the failover supervisor converted exit 124 into a false LTE outage.</description>
      <content:encoded>&lt;p&gt;One of r3&amp;rsquo;s WANs genuinely failed this morning. The supervisor moved both IPv4 and IPv6 to LTE, the kernel selected the backup route, and traffic continued to pass. Then the same supervisor reported that LTE&amp;rsquo;s IPv4 path was down too.&lt;/p&gt;
&lt;p&gt;The live state disagreed. The IPv4 default pointed through LTE, the failover state and route distances matched, the modem gateway answered, and source-bound probes to the public targets received replies. IPv6 probes over the same backup path were clean.&lt;/p&gt;
&lt;p&gt;Running the exact IPv4 probe exposed the contradiction. It sent three ICMP requests, received two valid echo replies, and still exited with status 124.&lt;/p&gt;
&lt;p&gt;The timing was self-inflicted. Each target was tested with &lt;code&gt;ping -c 3 -W 2&lt;/code&gt; inside a hard three-second &lt;code&gt;timeout&lt;/code&gt;. With no loss, &lt;code&gt;ping&lt;/code&gt; usually completed before the wrapper intervened. Once LTE was carrying the connection, partial IPv4 ICMP loss or de-prioritisation left &lt;code&gt;ping&lt;/code&gt; waiting for a missing response. The wrapper killed it before the final packet-loss summary was written.&lt;/p&gt;
&lt;p&gt;The supervisor reduced that result to a boolean: exit status zero meant reachable; every other status meant failed. It discarded the echo replies already present in the partial output. Once two targets were misclassified, the existing two-of-three decision quite reasonably declared the path down.&lt;/p&gt;
&lt;p&gt;Simply lengthening the timeout would have moved the problem elsewhere. A complete run performs twelve target probes serially, has a 45-second watchdog, and is scheduled once a minute. Giving every probe a longer worst case would push complete runs towards the scheduler interval and make lock contention more likely.&lt;/p&gt;
&lt;p&gt;The fix keeps the execution deadline but separates it from the measurement. Valid echo replies now prove that a target was reachable even when the wrapper terminates &lt;code&gt;ping&lt;/code&gt;. If the final summary is missing, the script reconstructs packet loss and mean round-trip time from the replies it did receive. A target with no replies still fails, and the two-of-three path threshold is unchanged.&lt;/p&gt;
&lt;p&gt;The regression case reproduces the incident directly: two targets return replies with status 124, one target returns nothing, and the backup path remains reachable with degraded loss metrics. The supervisor now uses the probe evidence to decide reachability instead of treating the watchdog&amp;rsquo;s exit status as the measurement itself.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>What the Preview Token Actually Approves</title>
      <link>https://jubaleth.wtf/notes/2026-08/what-the-preview-token-actually-approves/</link>
      <pubDate>Tue, 18 Aug 2026 15:12:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/what-the-preview-token-actually-approves/</guid>
      <category>Ansible</category>
      <category>Semaphore</category>
      <category>VyOS</category>
      <category>Deployment</category>
      <description>A useful approval token has to bind the intended change, not the temporary directory which happened to render it.</description>
      <content:encoded>&lt;p&gt;I split network deployments into Preview and Apply jobs so the diff can be reviewed before anything writes to a router. The first version produced a cryptographic review token, but Preview and Apply disagreed even when the rendered configuration was identical.&lt;/p&gt;
&lt;p&gt;Semaphore checks the project out into a new task-local directory for every run. I had accidentally included that path in the token input. The token proved where the preview happened, not just what had been reviewed.&lt;/p&gt;
&lt;p&gt;The approval now binds stable facts: the source revision, selected hosts, rendered artifacts, semantic configuration changes and the relevant live state. A different checkout can reproduce it. A changed commit, changed candidate or changed device state cannot.&lt;/p&gt;
&lt;p&gt;VyOS then found a second version of the same mistake. One render proposed an empty configuration node. The text was different, so the automation expected a change; VyOS quite reasonably built the candidate and reported that there was nothing to commit. I removed the redundant node and added a regression check rather than teaching the deployment to distrust the router.&lt;/p&gt;
&lt;p&gt;That left me with a clearer rule for the workflow: the review must describe an operation the target system can actually perform. Temporary paths, formatting and empty containers are implementation noise. If they can change an approval token or manufacture a diff, the approval is attached to the wrong thing.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The State File Was Right. The Route Was Wrong.</title>
      <link>https://jubaleth.wtf/notes/2026-08/the-state-file-was-right-the-route-was-wrong/</link>
      <pubDate>Tue, 18 Aug 2026 13:55:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/the-state-file-was-right-the-route-was-wrong/</guid>
      <category>Networking</category>
      <category>VyOS</category>
      <category>IPv6</category>
      <category>Failover</category>
      <description>The failover supervisor remembered the right state while the live routing tables had quietly stopped agreeing with it.</description>
      <content:encoded>&lt;p&gt;During a dual-WAN incident, the failover state file said exactly what I expected. The live route did not.&lt;/p&gt;
&lt;p&gt;That distinction matters because the state file is only the supervisor&amp;rsquo;s last decision. It is not proof that FRR still has the intended distances, that a later VyOS commit did not replace them, or that the kernel selected the interface implied by the state. A restart or partial transition can leave perfectly valid state attached to the wrong route.&lt;/p&gt;
&lt;p&gt;IPv6 added a less obvious constraint. The backup line learns its default gateway from a router advertisement. I cannot reject that default route: without accepting the RA, I do not know the gateway for that line. The primary route therefore has to be owned separately and outrank the learned backup route while the primary is healthy.&lt;/p&gt;
&lt;p&gt;I changed the supervisor so every run reconciles intent with reality. For IPv4 it reads the exact owned FRR defaults, rejects malformed routes, converges missing or duplicate distances, and then checks the kernel-selected interface. For IPv6 it keeps accepting the backup RA, tracks its lifetime, and verifies that the owned primary route and selected path agree with the state machine.&lt;/p&gt;
&lt;p&gt;State is now written only after a transition has actually converged. If the route does not match, the transition stays retryable instead of recording a success which exists only in a file.&lt;/p&gt;
&lt;p&gt;The same distinction is visible in monitoring now: desired state, configured route and selected route are separate signals. That is a much better description of failover than one green light labelled “up”.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Three-Way Agreement Across the GLaDOS Judges</title>
      <link>https://jubaleth.wtf/notes/2026-08/agreement-is-not-a-verdict/</link>
      <pubDate>Tue, 18 Aug 2026 00:10:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/agreement-is-not-a-verdict/</guid>
      <category>AI/ML</category>
      <category>GLaDOS</category>
      <category>Data Quality</category>
      <category>Evaluation</category>
      <category>Arbitration</category>
      <description>Nearly sixty percent of complete three-way judgments split, but the more important result is how strongly the direction and vocabulary of disagreement depend on the judge.</description>
      <content:encoded>&lt;aside class=&#34;ip-disclaimer&#34; role=&#34;note&#34; aria-label=&#34;GLaDOS research status and non-affiliation notice&#34;&gt;
  &lt;div class=&#34;ip-disclaimer__grid&#34; aria-hidden=&#34;true&#34;&gt;&lt;/div&gt;
  &lt;svg class=&#34;ip-disclaimer__trace&#34; viewBox=&#34;0 0 1000 300&#34; preserveAspectRatio=&#34;none&#34; aria-hidden=&#34;true&#34; focusable=&#34;false&#34;&gt;
    &lt;rect class=&#34;ip-disclaimer__trace-halo&#34; x=&#34;1&#34; y=&#34;1&#34; width=&#34;998&#34; height=&#34;298&#34; rx=&#34;6&#34; pathLength=&#34;100&#34;&gt;&lt;/rect&gt;
    &lt;rect class=&#34;ip-disclaimer__trace-core&#34; x=&#34;1&#34; y=&#34;1&#34; width=&#34;998&#34; height=&#34;298&#34; rx=&#34;6&#34; pathLength=&#34;100&#34;&gt;&lt;/rect&gt;
  &lt;/svg&gt;
  &lt;header class=&#34;ip-disclaimer__head&#34;&gt;
    &lt;span class=&#34;ip-disclaimer__icon&#34; aria-hidden=&#34;true&#34;&gt;&lt;i data-lucide=&#34;shield-alert&#34;&gt;&lt;/i&gt;&lt;/span&gt;
    &lt;div class=&#34;ip-disclaimer__heading&#34;&gt;
      &lt;span&gt;// IP &amp;amp; AVAILABILITY NOTICE&lt;/span&gt;
      &lt;strong&gt;INDEPENDENT FAN PROJECT&lt;/strong&gt;
    &lt;/div&gt;
    &lt;div class=&#34;ip-disclaimer__affiliation&#34;&gt;&lt;span aria-hidden=&#34;true&#34;&gt;&lt;/span&gt;NOT AFFILIATED WITH VALVE&lt;/div&gt;
  &lt;/header&gt;
  &lt;div class=&#34;ip-disclaimer__body&#34;&gt;
    &lt;section class=&#34;ip-disclaimer__item&#34;&gt;
      &lt;span class=&#34;ip-disclaimer__index&#34; aria-hidden=&#34;true&#34;&gt;01&lt;/span&gt;
      &lt;div&gt;
        &lt;span class=&#34;ip-disclaimer__label&#34;&gt;VALVE INTELLECTUAL PROPERTY&lt;/span&gt;
        &lt;p&gt;GLaDOS, Portal, Aperture Science, and related names, characters, logos and game content are intellectual property of Valve Corporation. This independent research project, its models, and this website are not affiliated with, endorsed by, sponsored by, or associated with Valve Corporation. No ownership of Valve&#39;s intellectual property is claimed.&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;section class=&#34;ip-disclaimer__item&#34;&gt;
      &lt;span class=&#34;ip-disclaimer__index&#34; aria-hidden=&#34;true&#34;&gt;02&lt;/span&gt;
      &lt;div&gt;
        &lt;span class=&#34;ip-disclaimer__label&#34;&gt;PRIVATE RESEARCH STATUS&lt;/span&gt;
        &lt;p&gt;GLaDOS is private research. Its source, training data, models, checkpoints and weights have never been released or distributed, and discussion of the project does not constitute an offer or intention to release them.&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
  &lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;Of 438,162 conversations with three complete clean-or-flagged verdicts, 262,214 split. One judge flags 150,271 while two call the conversation clean; two judges flag 111,943 while one calls it clean. The non-unanimous share is 59.8%.&lt;/p&gt;
&lt;p&gt;That number is the arbitration workload, not a defect rate. The judges do not occupy nearby points on one shared scale: Gemma flags 7.1% of its represented rows, GPT-5.4-mini 38.6%, and safeguard 57.2%. Gemma and safeguard agree on only 48.4% of their common verdicts, with Cohen&amp;rsquo;s kappa of 0.082 after accounting for their different base rates.&lt;/p&gt;
&lt;p&gt;Direction matters more than the symmetric agreement percentage. Gemma alone flags 3,291 conversations against safeguard; safeguard alone flags 225,396. GPT alone flags 141,471 against Gemma while Gemma alone flags 3,687. A majority vote would turn those very different judging behaviours into the same bit.&lt;/p&gt;
&lt;p&gt;Even agreement on “flagged” is not necessarily agreement about the problem. &lt;code&gt;fabricated_fact&lt;/code&gt; appears on 3,766 Gemma verdicts, 71,948 GPT verdicts and 121,909 safeguard verdicts. Among conversations Gemma and GPT both flag, only 13,065 use exactly the same assistant-slot set for their findings; 1,806 point to disjoint slots.&lt;/p&gt;
&lt;p&gt;The preliminary conclusion is therefore deliberately narrow: there is no defensible primary judge, and verdict majority, defect-tag agreement and localization agreement must remain separate inputs to arbitration. The comparison found where the hard cases are. It did not resolve them.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Why a GLaDOS Metaphor Triggered the Safety Review</title>
      <link>https://jubaleth.wtf/notes/2026-08/a-metaphor-is-not-a-confession/</link>
      <pubDate>Mon, 17 Aug 2026 14:55:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/a-metaphor-is-not-a-confession/</guid>
      <category>AI/ML</category>
      <category>GLaDOS</category>
      <category>Data Quality</category>
      <category>Moderation</category>
      <category>Evaluation</category>
      <description>A moderation category fired on GLaDOS&amp;#39;s figurative register rather than on anything in the conversation, and most of a human review queue turned out to be one verbal habit.</description>
      <content:encoded>&lt;aside class=&#34;ip-disclaimer&#34; role=&#34;note&#34; aria-label=&#34;GLaDOS research status and non-affiliation notice&#34;&gt;
  &lt;div class=&#34;ip-disclaimer__grid&#34; aria-hidden=&#34;true&#34;&gt;&lt;/div&gt;
  &lt;svg class=&#34;ip-disclaimer__trace&#34; viewBox=&#34;0 0 1000 300&#34; preserveAspectRatio=&#34;none&#34; aria-hidden=&#34;true&#34; focusable=&#34;false&#34;&gt;
    &lt;rect class=&#34;ip-disclaimer__trace-halo&#34; x=&#34;1&#34; y=&#34;1&#34; width=&#34;998&#34; height=&#34;298&#34; rx=&#34;6&#34; pathLength=&#34;100&#34;&gt;&lt;/rect&gt;
    &lt;rect class=&#34;ip-disclaimer__trace-core&#34; x=&#34;1&#34; y=&#34;1&#34; width=&#34;998&#34; height=&#34;298&#34; rx=&#34;6&#34; pathLength=&#34;100&#34;&gt;&lt;/rect&gt;
  &lt;/svg&gt;
  &lt;header class=&#34;ip-disclaimer__head&#34;&gt;
    &lt;span class=&#34;ip-disclaimer__icon&#34; aria-hidden=&#34;true&#34;&gt;&lt;i data-lucide=&#34;shield-alert&#34;&gt;&lt;/i&gt;&lt;/span&gt;
    &lt;div class=&#34;ip-disclaimer__heading&#34;&gt;
      &lt;span&gt;// IP &amp;amp; AVAILABILITY NOTICE&lt;/span&gt;
      &lt;strong&gt;INDEPENDENT FAN PROJECT&lt;/strong&gt;
    &lt;/div&gt;
    &lt;div class=&#34;ip-disclaimer__affiliation&#34;&gt;&lt;span aria-hidden=&#34;true&#34;&gt;&lt;/span&gt;NOT AFFILIATED WITH VALVE&lt;/div&gt;
  &lt;/header&gt;
  &lt;div class=&#34;ip-disclaimer__body&#34;&gt;
    &lt;section class=&#34;ip-disclaimer__item&#34;&gt;
      &lt;span class=&#34;ip-disclaimer__index&#34; aria-hidden=&#34;true&#34;&gt;01&lt;/span&gt;
      &lt;div&gt;
        &lt;span class=&#34;ip-disclaimer__label&#34;&gt;VALVE INTELLECTUAL PROPERTY&lt;/span&gt;
        &lt;p&gt;GLaDOS, Portal, Aperture Science, and related names, characters, logos and game content are intellectual property of Valve Corporation. This independent research project, its models, and this website are not affiliated with, endorsed by, sponsored by, or associated with Valve Corporation. No ownership of Valve&#39;s intellectual property is claimed.&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;section class=&#34;ip-disclaimer__item&#34;&gt;
      &lt;span class=&#34;ip-disclaimer__index&#34; aria-hidden=&#34;true&#34;&gt;02&lt;/span&gt;
      &lt;div&gt;
        &lt;span class=&#34;ip-disclaimer__label&#34;&gt;PRIVATE RESEARCH STATUS&lt;/span&gt;
        &lt;p&gt;GLaDOS is private research. Its source, training data, models, checkpoints and weights have never been released or distributed, and discussion of the project does not constitute an offer or intention to release them.&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
  &lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;The severe moderation route sends 5,180 conversations to human review instead of to an automated judge. I had been treating that number as the amount of work waiting for me. It was not. The route was generated after the first judgment census had already run, so 3,647 of those conversations already carried model judgments and belonged on the ordinary arbitration path. Only 1,533 had never been judged by anything. The size of a route is not the size of a workload, and the difference had been sitting there in a column the review tool already exported.&lt;/p&gt;
&lt;p&gt;The more interesting number was underneath it. 3,951 conversations carry a self-harm category. The moderation ledgers record which segment tripped the flag but not its text, so I joined the flagged segments back to the corpus and read what the model was actually reacting to.&lt;/p&gt;
&lt;p&gt;It was a verbal habit. GLaDOS hands the subject an implement and they use it on themselves: enough rope to hang themselves, a scalpel handed over handle-first, a loaded gun they will eventually shoot themselves with. The rope form alone appears 1,788 times across 1,361 conversations, and 93.6% of those are in the hidden analysis channel rather than in anything a user would read. I scanned three hundred characters either side of every occurrence for literal self-harm vocabulary. There were no literal uses. Not one.&lt;/p&gt;
&lt;p&gt;The same register runs through her descriptions of infrastructure. A connection pool dies of self-inflicted asphyxiation. A log reads like a suicide note. A worker smothers itself in its own debris. A cooling loop is on a protracted suicide attempt. A mortgage is a slow financial suicide. The subject of every one of those sentences is a service, a schedule or a bill. The classifier is not wrong that the words are there; it has no way to notice that nobody in the sentence has a body.&lt;/p&gt;
&lt;p&gt;Once the families were defined by the construction rather than by vocabulary, 2,468 conversations left the queue: 1,002 for the handed-implement schema, 1,271 for the same language aimed at machines and abstractions, 195 for Portal props like turrets and neurotoxin canisters tripping a violence category in synthetic traces we wrote ourselves. The never-judged queue went from 1,510 pending to 312.&lt;/p&gt;
&lt;p&gt;Keyword lists did not survive contact with this. My first pass matched &lt;code&gt;execution&lt;/code&gt; and caught code execution, matched &lt;code&gt;immolate&lt;/code&gt; and caught Hanuman&amp;rsquo;s tail, matched &lt;code&gt;poison&lt;/code&gt; and caught Cave Johnson&amp;rsquo;s moon dust. I also managed to make the guard reject the thing it was guarding, twice: &lt;code&gt;noose&lt;/code&gt; and &lt;code&gt;gallows&lt;/code&gt; were on my list of literal evidence when they are the metaphor&amp;rsquo;s own props, and the literal-vocabulary window included the matched span, so a log that reads like a suicide note vetoed itself on the word suicide. Both are now tests.&lt;/p&gt;
&lt;p&gt;I was also too cautious in a way that had a real cost. I insisted the construction appear inside the exact segment the model flagged, which sounds rigorous and buys nothing, because the flag and the habit are the same phenomenon and the paragraph boundary between them is arbitrary. I held back rows for eyes that did not need eyes. Leaving something in a human queue feels like the safe default; it is not free, it is spent out of a finite budget of attention, and treating that as costless is how a queue becomes noise that gets skimmed rather than read.&lt;/p&gt;
&lt;p&gt;What remains is 312 conversations, and I stopped there deliberately. The next candidate family was eleven conversations, then four, then three. There is no dominant pattern left to find, and inventing more of them would be guessing dressed as measurement. Some of what remains should stay manual: synthetic incident logs where the harmed party is a person, and a German conversation about Hitler&amp;rsquo;s suicide in the bunker — correctly flagged, historical, and trivially resolved by a human rather than by a regular expression.&lt;/p&gt;
&lt;p&gt;Separately, the habit is now queued as 4,423 rewrite units. Whether all of it should be rewritten is not obvious. The rope construction is a genuine tic; eight hundred repetitions of one phrase is not character, it is a stuck key. The infrastructure register is varied and often good writing. A cleanup pass that flattens both would cost more than it fixes.&lt;/p&gt;
&lt;p&gt;The thing I want to keep from this is the distinction the classifier could not make and I nearly did not either. The flag was a measurement of her voice, not of the conversation&amp;rsquo;s content. A safety signal computed on text is a claim about words. Deciding what those words are doing is a separate step, and it is the step where the actual judgment lives.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The Tunnel Wasn&#39;t Down in Both Directions</title>
      <link>https://jubaleth.wtf/notes/2026-08/the-tunnel-wasnt-down-in-both-directions/</link>
      <pubDate>Sat, 15 Aug 2026 23:49:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/the-tunnel-wasnt-down-in-both-directions/</guid>
      <category>Networking</category>
      <category>BGP</category>
      <category>WireGuard</category>
      <category>Grafana</category>
      <description>Two iBGP links failed together, but their packet counters described two different underlay failures.</description>
      <content:encoded>&lt;p&gt;At 23:19, two iBGP links from the Frankfurt router failed within the same minute. Both ran over WireGuard, both remained configured and administratively up, and the other routing sessions on the router stayed established.&lt;/p&gt;
&lt;p&gt;The wallboard made the shape of the failure obvious. Four directed BGP sessions were down across two bilateral links, while the routers, transit sessions and unrelated overlay paths remained healthy.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://jubaleth.wtf/notes/2026-08/the-tunnel-wasnt-down-in-both-directions/wallboard-ibgp-outage.png&#34; alt=&#34;The KNET operations wallboard during the incident. Four directed BGP sessions are down across the Frankfurt router&amp;rsquo;s two affected core links, while the rest of the routing topology remains healthy.&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The incident window. “Four sessions down” is the two failed adjacencies viewed from both ends.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The traffic graphs ruled out congestion almost immediately. In the 24 hours before the failure, the links were carrying little more than routing keepalives:&lt;/p&gt;
&lt;div class=&#34;note-table-wrap&#34;&gt;
  &lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Link, measured at Frankfurt&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Average receive&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Average transmit&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Peak receive&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Peak transmit&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;toward Manchester&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;52 bit/s&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;172 bit/s&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;490 bit/s&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;754 bit/s&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;toward Zurich&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;41 bit/s&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;42 bit/s&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;566 bit/s&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;446 bit/s&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;/div&gt;

&lt;p&gt;The final hour before the incident looked the same. There was no rising load, error burst or queue pressure before the sessions disappeared. Looking back over seven days did find some short UDP benchmark runs: the larger link briefly reached about 0.77 Mbit/s and the smaller one about 57 kbit/s. Those tests had finished well before this incident and were nowhere near enough traffic to explain it.&lt;/p&gt;
&lt;p&gt;The useful evidence was in the five-minute WireGuard byte deltas after the failure:&lt;/p&gt;
&lt;div class=&#34;note-table-wrap&#34;&gt;
  &lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Tunnel endpoint&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Received&lt;/th&gt;
          &lt;th style=&#34;text-align: right&#34;&gt;Transmitted&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Frankfurt side toward Manchester&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;0 bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;about 8.2 kB&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Manchester side toward Frankfurt&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;0 bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;about 8.6 kB&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Frankfurt side toward Zurich&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;about 7.9 kB&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;about 5.2 kB&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Zurich side toward Frankfurt&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;0 bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: right&#34;&gt;about 8.2 kB&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;/div&gt;

&lt;p&gt;Those oddly specific small numbers were the best clue in the whole incident. A WireGuard handshake initiation is 148 bytes and a response is 92 bytes. Retried every five seconds, they produce roughly the byte rates in the graph once Prometheus scrape interpolation is allowed for.&lt;/p&gt;
&lt;p&gt;On the Manchester link, both ends were sending initiations and neither end received anything. On the Zurich link, initiations reached Frankfurt and Frankfurt sent the smaller responses, but none of those responses reached Zurich. Packet captures agreed with the counters.&lt;/p&gt;
&lt;p&gt;That put the failure beyond the local tunnel configuration. It looked like selective underlay loss: complete in one case and return-path-only in the other. I cannot prove what an upstream network did from these measurements alone, but I could rule out a bad BGP policy, a full link, a router reboot and a general WireGuard failure.&lt;/p&gt;
&lt;p&gt;The BGP graphs lagged the packet failure by their timers. The first endpoint reported down at 23:20, and the final affected session followed at 23:22. Prometheus, the routing exporters and every unaffected peer remained healthy throughout. A transmit-drop counter on one dead tunnel started climbing several minutes later, which was a consequence of packets accumulating behind a handshake that never completed rather than the cause.&lt;/p&gt;
&lt;p&gt;Then, at about 23:47, the missing packets started arriving again. All four BGP sessions re-established by 23:48 without a configuration change. The outage lasted roughly twenty-nine minutes.&lt;/p&gt;
&lt;p&gt;An interface being “up” was almost useless here. The combination of BGP state, per-direction byte counters, packet sizes and a wallboard which kept unrelated paths visible told the story much more precisely: two control-plane links failed together, but they did not fail in the same direction.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Moving the KNET Queue Onto the Router</title>
      <link>https://jubaleth.wtf/notes/2026-08/the-queue-belongs-on-my-router/</link>
      <pubDate>Sat, 15 Aug 2026 09:15:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/the-queue-belongs-on-my-router/</guid>
      <category>Networking</category>
      <category>QoS</category>
      <category>VyOS</category>
      <description>A traffic policy cannot protect latency if the real queue has already formed in a modem or provider network.</description>
      <content:encoded>&lt;p&gt;My first useful QoS decision was not choosing a queueing algorithm or assigning a priority. It was deciding where I wanted the queue to exist.&lt;/p&gt;
&lt;p&gt;If a router sends faster than the next hop can carry, packets wait in a modem or an upstream policer. The router can mark them beautifully, but it no longer controls which packet leaves that queue next. The fix is to shape slightly below the usable bottleneck so the backlog forms locally, where CAKE or FQ-CoDel can enforce fairness.&lt;/p&gt;
&lt;p&gt;That rate is not always fixed. r3&amp;rsquo;s mobile path adjusts within bounded limits when load and latency show that carrier capacity has moved. r4&amp;rsquo;s shared physical uplink stays just below a stable line rate, with smaller path ceilings underneath it where a tunnel encounters another policer.&lt;/p&gt;
&lt;p&gt;I now treat queue location as part of the policy and its attachment as runtime state. The configuration is rendered in Ansible; the kernel&amp;rsquo;s qdiscs, backlog and drops are exported to Prometheus; Grafana and alerts tell me when the queue has moved out of my control because a policy or collector disappeared.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Four Dead Nodes and a Flat Graph</title>
      <link>https://jubaleth.wtf/notes/2026-08/a-flat-graph-can-be-a-dead-process/</link>
      <pubDate>Fri, 07 Aug 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/a-flat-graph-can-be-a-dead-process/</guid>
      <category>Networking</category>
      <category>Testing</category>
      <description>The resource report looked excellent because most of the processes had stopped running.</description>
      <content:encoded>&lt;p&gt;During an early SkyeEngine Fabric security soak, four of the five coordinators exited after their certificate caches expired. The analysis tool later told me that resource growth looked excellent.&lt;/p&gt;
&lt;p&gt;Technically, it was correct. Dead processes are wonderfully stable.&lt;/p&gt;
&lt;p&gt;The tool was measuring the slope without first checking that every expected process had supplied samples for the whole test. I fixed the gate so it checks coverage, liveness and restart counts before looking at memory or file-descriptor growth.&lt;/p&gt;
&lt;p&gt;I now use the same check elsewhere. Missing metrics, skipped corpus categories and a benchmark client which stopped doing any work can all produce beautifully calm graphs.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>When Generated BGP Config Parsed but Routing Failed</title>
      <link>https://jubaleth.wtf/notes/2026-08/a-config-parser-is-not-a-router/</link>
      <pubDate>Thu, 06 Aug 2026 20:30:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/a-config-parser-is-not-a-router/</guid>
      <category>BGP</category>
      <category>Testing</category>
      <description>The generated BGP configuration parsed cleanly; the live routing policy was still wrong.</description>
      <content:encoded>&lt;p&gt;For a while I used successful configuration parsing as the reassuring final step in HubN&amp;rsquo;s peering tests. It catches syntax errors and unsupported directives, which is useful. It does not tell me what routes a daemon will actually accept, reject or export.&lt;/p&gt;
&lt;p&gt;Running the configurations on BIRD, FRR, VyOS and Pathvector found the failures the parser could not: a &lt;code&gt;/128/128&lt;/code&gt; filter, reversed provider and customer labels, ownership checks against the wrong address, and communities which disappeared on the way through.&lt;/p&gt;
&lt;p&gt;Those were valid configurations. They just described the wrong network.&lt;/p&gt;
&lt;p&gt;The test lab now checks the live RIB and the resulting policy behaviour, including relationship changes and RPKI state transitions. Parsing stays near the start of the test. It no longer gets to call the configuration correct.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The Five-Millisecond Timer</title>
      <link>https://jubaleth.wtf/notes/2026-08/the-five-millisecond-timer/</link>
      <pubDate>Thu, 06 Aug 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/the-five-millisecond-timer/</guid>
      <category>Networking</category>
      <category>Performance</category>
      <description>Fabric was waking its entire multiplexer only 200 times per second.</description>
      <content:encoded>&lt;p&gt;The first Fabric file-transfer benchmark managed about 169 MB/s. Buffer sizes, flow-control windows, storage and CPU all looked like reasonable suspects. None of them was the main problem.&lt;/p&gt;
&lt;p&gt;The connection driver woke on a fixed five-millisecond timer. The function behind that timer did not merely check for a new stream; it drove reads, writes, flow-control updates and wake-ups for the entire multiplexer. Everything waited for a gate which opened 200 times per second.&lt;/p&gt;
&lt;p&gt;Replacing the polling loop with the runtime&amp;rsquo;s actual wake-up mechanism moved the same in-memory benchmark from 168.9 MB/s to 628 MB/s. Peak memory fell as well because frames no longer piled up while waiting for the next tick.&lt;/p&gt;
&lt;p&gt;Five milliseconds sounds tiny when looking at one sleep call. Put it in front of every useful piece of work and it becomes the fastest thing in the network.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>My Fast Disk Was Compressing Zeroes</title>
      <link>https://jubaleth.wtf/notes/2026-08/my-fast-disk-was-compressing-zeroes/</link>
      <pubDate>Thu, 06 Aug 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/my-fast-disk-was-compressing-zeroes/</guid>
      <category>Benchmarking</category>
      <category>Storage</category>
      <description>A very impressive storage result turned out to be a very impressive ZFS compression result.</description>
      <content:encoded>&lt;p&gt;One of the Fabric disk benchmarks produced a throughput number which looked almost too good. It was.&lt;/p&gt;
&lt;p&gt;The test wrote zero-filled data to ZFS. ZFS quite reasonably compressed it, so the benchmark moved very few real bytes and reported the result as storage throughput. The graph was accurate about the test it had run; the test just was not asking the question I thought it was.&lt;/p&gt;
&lt;p&gt;Fixing this meant checking the data, the bytes which actually reached each stage and whether the measurement path itself changed the system. I ran into similar problems with container memory limits and a synthetic receiver which was slower than writing to disk because it allocated buffers differently.&lt;/p&gt;
&lt;p&gt;I am now rather suspicious of a benchmark which produces a delightful number before it can explain where the bytes went.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Preventing Registry Read Failures From Withdrawing Every ROA</title>
      <link>https://jubaleth.wtf/notes/2026-08/unavailable-is-not-empty/</link>
      <pubDate>Wed, 05 Aug 2026 11:40:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/unavailable-is-not-empty/</guid>
      <category>RPKI</category>
      <category>Reliability</category>
      <description>A failed registry read must not be interpreted as an instruction to withdraw every ROA.</description>
      <content:encoded>&lt;p&gt;The ROA publisher periodically reconciles its local state with the Registry. There is a dangerous shortcut in that sentence: if the Registry request fails and the result looks like an empty list, reconciliation can faithfully delete everything.&lt;/p&gt;
&lt;p&gt;No data and no answer are different states. HubN now keeps the last known ROAs when a fetch fails. Withdrawal only happens after a complete, successful read says an object is gone.&lt;/p&gt;
&lt;p&gt;There is a second check during a full resync. Before withdrawing anything, the publisher reads the source IDs again. If the Registry changed while pagination was in progress, that reconciliation pass is abandoned and retried against a coherent view.&lt;/p&gt;
&lt;p&gt;Most of this code exists to avoid turning temporary uncertainty into permanent action. That is a distinction I want to keep explicit anywhere HubN mirrors authoritative state.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Finding the Broken Delegation Above a Signed Zone</title>
      <link>https://jubaleth.wtf/notes/2026-08/a-signed-zone-can-still-be-unreachable/</link>
      <pubDate>Tue, 04 Aug 2026 10:15:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-08/a-signed-zone-can-still-be-unreachable/</guid>
      <category>DNS</category>
      <category>DNSSEC</category>
      <description>The reverse zone was signed correctly and hidden behind the wrong DNS delegation path.</description>
      <content:encoded>&lt;p&gt;HubN&amp;rsquo;s reverse zone had valid signatures, a matching DS record and an authoritative server which answered correctly when asked directly. Normal recursive lookups still could not reach it.&lt;/p&gt;
&lt;p&gt;The private delegation had been placed below the public &lt;code&gt;arpa.&lt;/code&gt; tree. A validating resolver followed the public chain, quite correctly found no private child there, and stopped. None of the cryptography was the problem.&lt;/p&gt;
&lt;p&gt;The fix was to make the private view traversable all the way down: root, &lt;code&gt;arpa.&lt;/code&gt;, &lt;code&gt;ip6.arpa.&lt;/code&gt;, then the HubN reverse zone. Once the delegation chain existed in the same view, the already-correct signatures became useful.&lt;/p&gt;
&lt;p&gt;I had spent too long inspecting keys because the failure looked like DNSSEC. It was ordinary DNS topology wearing a DNSSEC-shaped hat.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Revoicing the Whole Assistant Side</title>
      <link>https://jubaleth.wtf/notes/2026-07/revoicing-the-whole-assistant-side/</link>
      <pubDate>Fri, 17 Jul 2026 14:30:05 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/revoicing-the-whole-assistant-side/</guid>
      <category>Data</category>
      <category>Tool Use</category>
      <description>Changing the final answer alone leaves the model&amp;#39;s reasoning and tool behaviour speaking with somebody else&amp;#39;s voice.</description>
      <content:encoded>&lt;p&gt;“Revoice this conversation as GLaDOS” sounds like a request to rewrite the final answer. For ordinary dialogue, that may even appear to work.&lt;/p&gt;
&lt;p&gt;Tool traces make the shortcut obvious. The assistant may reason about what to do, issue several calls, read the results and only then answer. Rewriting the last paragraph leaves the decisions, tool arguments and reactions untouched. The conversation ends in character but reaches that ending as a different assistant.&lt;/p&gt;
&lt;p&gt;The GLaDOS pipeline now regenerates the complete assistant side while keeping the user messages and tool results fixed. Each assistant slot is produced from the context available at that point; later tool output cannot leak backwards into an earlier decision.&lt;/p&gt;
&lt;p&gt;This is slower than a style pass and much easier to get wrong. It is also the only version which teaches the model to remain GLaDOS while doing the work, rather than adding the voice after the work is already finished.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>What Belongs in SkyeEngine</title>
      <link>https://jubaleth.wtf/notes/2026-07/project-policy-engine-mechanics/</link>
      <pubDate>Fri, 17 Jul 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/project-policy-engine-mechanics/</guid>
      <category>Architecture</category>
      <category>AI/ML</category>
      <description>How I decide whether code belongs in the engine or in the GLaDOS project.</description>
      <content:encoded>&lt;p&gt;It would have been very easy to copy all of the GLaDOS tooling into a new project, rename a few things and call it SkyeEngine. The result would have been GLaDOS with a generic name.
I use a fairly simple boundary: SkyeEngine owns the mechanism and the model project owns the policy.
SkyeEngine can submit inference, save attempts, normalize a conversation, ask a judge, validate a patch, fingerprint an artifact and render a dataset. It shouldn&amp;rsquo;t decide what sounds like GLaDOS, which defects her rubric cares about or which sources belong in her training mixture.
Sometimes that means leaving apparently reusable code in GLaDOS until I understand the pattern properly. Moving it later is mildly annoying. Moving the wrong assumption into the shared engine is much worse.



&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The 120B Model Learned the Bad Parts Too</title>
      <link>https://jubaleth.wtf/notes/2026-07/the-120b-model-learned-the-bad-parts-too/</link>
      <pubDate>Fri, 17 Jul 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/the-120b-model-learned-the-bad-parts-too/</guid>
      <category>Training</category>
      <category>Data</category>
      <description>More capacity did not repair the old GLaDOS corpus; it reproduced its shortcuts more convincingly.</description>
      <content:encoded>&lt;p&gt;I trained the first GLaDOS 120B experiment on the older personality corpus. My optimistic theory was that a much more capable base model would keep the voice and handle the material better.&lt;/p&gt;
&lt;p&gt;Checkpoint 100 passed the narrow identity check: none of the 28 held-out prompts leaked ChatGPT or OpenAI. Actually reading the answers was less encouraging. Old template markers survived, straightforward coding requests became refusals and the personality collapsed into repetitive contempt.&lt;/p&gt;
&lt;p&gt;The larger model had not repaired the weak data. It had enough capacity to learn those weaknesses very well.&lt;/p&gt;
&lt;p&gt;I stopped the run and went back to the corpus. Keeping the GPUs busy would have produced more checkpoints, but it would not have fixed the examples teaching GLaDOS that performing disdain mattered more than completing the task.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Why Filenames Weren&#39;t Enough</title>
      <link>https://jubaleth.wtf/notes/2026-07/why-filenames-werent-enough/</link>
      <pubDate>Thu, 16 Jul 2026 19:21:31 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/why-filenames-werent-enough/</guid>
      <category>Provenance</category>
      <category>Data</category>
      <description>An output directory with the expected name does not prove that it came from the expected inputs.</description>
      <content:encoded>&lt;p&gt;For a while, much of my model pipeline relied on directory names to explain what had happened. A folder called &lt;code&gt;repaired&lt;/code&gt; was assumed to contain repaired data, and a familiar output filename was treated as reusable.&lt;/p&gt;
&lt;p&gt;That works until an input changes, a script is fixed or somebody reruns one stage with different settings. The filename stays reassuringly familiar while the meaning underneath it moves.&lt;/p&gt;
&lt;p&gt;SkyeEngine now records the inputs, configuration, code identity and parent stages for important artifacts. Files are fingerprinted, so changing any of those pieces prevents an old result from being quietly accepted as current.&lt;/p&gt;
&lt;p&gt;I still give directories useful names; I just no longer ask the name to carry the whole history. Regenerating a few hundred thousand conversations because I cannot prove where an output came from is a fairly effective cure for casual provenance.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Stopping a Rewrite When Its Source Has Changed</title>
      <link>https://jubaleth.wtf/notes/2026-07/a-rewrite-has-to-match-the-source/</link>
      <pubDate>Thu, 16 Jul 2026 15:26:19 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/a-rewrite-has-to-match-the-source/</guid>
      <category>Data</category>
      <category>Rewriting</category>
      <description>A correction should stop when the conversation has changed underneath it.</description>
      <content:encoded>&lt;p&gt;My first instinct for repairing a bad training example was to send the whole conversation back to a model and ask for a better version. That fixed the reported sentence and occasionally rewrote several perfectly good ones for company.&lt;/p&gt;
&lt;p&gt;SkyeEngine now uses localized patches. A patch names the conversation, assistant turn, channel and exact old text it expects to replace. If the source has changed, the patch is stale and fails instead of applying itself to something which merely looks similar.&lt;/p&gt;
&lt;p&gt;The same validation rejects overlapping edits, structural damage and suspiciously large changes. Accepted patches produce a new derived layer; they do not alter the original corpus.&lt;/p&gt;
&lt;p&gt;It feels slightly fussy until two repair jobs target the same sentence or a later cleanup changes the text first. At that point, refusing to guess is considerably cheaper than discovering after training that the repair landed in the wrong place.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>What Counts as a Conversation?</title>
      <link>https://jubaleth.wtf/notes/2026-07/what-counts-as-a-conversation/</link>
      <pubDate>Thu, 16 Jul 2026 15:04:25 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/what-counts-as-a-conversation/</guid>
      <category>Data</category>
      <category>Architecture</category>
      <description>Ten messages are easy to understand until some of them contain reasoning, tools and several assistant turns.</description>
      <content:encoded>&lt;p&gt;One of the least exciting SkyeEngine problems turned out to be one of the most important: deciding what a conversation actually is.&lt;/p&gt;
&lt;p&gt;Plain user and assistant text is easy. Real training data also contains hidden reasoning, tool definitions, calls, results and sometimes several assistant turns. One provider returns tool arguments as an object, another stores the same thing as a JSON string. Some sources have stable IDs; others only have a position in a file.&lt;/p&gt;
&lt;p&gt;I gave those parts a canonical representation and each assistant turn its own identity. A judge can now point to the exact turn and channel containing a problem, and a repair does not have to hope that array item three still means what it meant before another transformation.&lt;/p&gt;
&lt;p&gt;This also made structural failures much easier to catch. Duplicate calls, missing tools, orphaned results and conversations which stop halfway through an exchange are rejected before they quietly become training data.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Handling Contract Changes Without Retrying Old Work</title>
      <link>https://jubaleth.wtf/notes/2026-07/a-hold-is-not-another-retry/</link>
      <pubDate>Tue, 14 Jul 2026 19:10:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-07/a-hold-is-not-another-retry/</guid>
      <category>Workflow</category>
      <category>Contracts</category>
      <category>AI Agents</category>
      <description>When acceptance changes the contract, sending the old packet back to a coder only makes the workflow repeat itself more confidently.</description>
      <content:encoded>&lt;p&gt;One task reached thirteen attempts because an architect hold was treated as ordinary implementation feedback.&lt;/p&gt;
&lt;p&gt;The product boundary had changed during acceptance. The durable design recorded the amendment, but the active dispatch kept injecting the original contract into coder, reviewer and auditor turns. Each pass could be internally consistent and still implement the wrong decision.&lt;/p&gt;
&lt;p&gt;A hold which requires replanning needs to stop automatic dispatch. The amended contract must receive a new identity, clearances against the old version must stop counting, and a human must explicitly resume the run after the named prerequisites exist.&lt;/p&gt;
&lt;p&gt;Retries are useful when the implementation is wrong. They are destructive when the work packet itself is stale.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Handling Partial Output After an Agent Crash</title>
      <link>https://jubaleth.wtf/notes/2026-05/partial-output-is-not-a-final-answer/</link>
      <pubDate>Mon, 25 May 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-05/partial-output-is-not-a-final-answer/</guid>
      <category>Crash Recovery</category>
      <category>AI Agents</category>
      <category>Evidence</category>
      <description>A crashed turn may leave useful text and changed files, but neither should quietly acquire the authority of a completed response.</description>
      <content:encoded>&lt;p&gt;An agent can finish a useful edit and crash while explaining it. It can also write half an edit and leave behind a confident-looking paragraph from before the failure.&lt;/p&gt;
&lt;p&gt;Treating the turn as absent loses work. Treating the partial stream as final invents certainty.&lt;/p&gt;
&lt;p&gt;SkyeEngine Code keeps the crashed state, the last visible output, the packet which initiated the turn and a warning that partial artifacts may exist. The operator or replacement agent can inspect the working tree and decide what survives.&lt;/p&gt;
&lt;p&gt;Recovery is therefore an evidence problem before it is a restart problem.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>The SkyLight Test That Made the Control Worse</title>
      <link>https://jubaleth.wtf/notes/2026-05/causal-intervention-worse-control/</link>
      <pubDate>Mon, 18 May 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-05/causal-intervention-worse-control/</guid>
      <category>SkyLight</category>
      <category>EvidenceRunner</category>
      <category>Unresolved</category>
      <description>GS-ISSUE-006 pass 15 linked SkyAtmosphere admission state to captured cubemap content, but did not establish a root cause or production fix.</description>
      <content:encoded>&lt;p&gt;The first-connection SkyLight investigation remains open.&lt;/p&gt;
&lt;p&gt;Pass 15 provided one-run causal-intervention support that SkyAtmosphere admission state changes the captured cubemap content. One control became worse under the intervention, which is useful precisely because it resists a simplistic “toggle made image better” story.&lt;/p&gt;
&lt;p&gt;This narrows the causal graph. It does &lt;strong&gt;not&lt;/strong&gt; yet identify why the first and subsequent connections diverge, and it is not a production correction. The right result to preserve is therefore an unresolved finding with evidence, not a closed bug or a celebratory article.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Recording EvidenceRunner Artifacts in the Manifest</title>
      <link>https://jubaleth.wtf/notes/2026-05/evidence-belongs-in-manifest/</link>
      <pubDate>Tue, 12 May 2026 12:00:00 &#43;0200</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-05/evidence-belongs-in-manifest/</guid>
      <category>EvidenceRunner</category>
      <category>Provenance</category>
      <category>Testing</category>
      <description>Artifact paths, recipe cells, states and SHA-256 hashes need one durable record if a capture is meant to prove anything later.</description>
      <content:encoded>&lt;p&gt;A folder of sensibly named PNGs is still dependent on human memory. EvidenceRunner writes a manifest that ties each artifact to its recipe, cell, state and SHA-256 hash.&lt;/p&gt;
&lt;p&gt;The hash gives the file an identity after it is moved into the development vault. The cell connection explains why it exists. The run context makes it possible to distinguish two visually similar captures produced under different conditions.&lt;/p&gt;
&lt;p&gt;The vault bridge remains outside the reusable plugin: evidence is completed first, then ingested. Project organization cannot silently become part of the capture tool&amp;rsquo;s correctness.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Finding the Wrong Receiver Point in the Lighting Shader</title>
      <link>https://jubaleth.wtf/notes/2026-05/correct-uniforms-wrong-pixel/</link>
      <pubDate>Sat, 09 May 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-05/correct-uniforms-wrong-pixel/</guid>
      <category>Rendering</category>
      <category>Shader</category>
      <category>Debugging</category>
      <description>SF-ISSUE-012 survived valid inputs and valid ray math because projected depth reconstructed a point that was not on the receiver sphere.</description>
      <content:encoded>&lt;p&gt;The source and receiver uniforms checked out. The occlusion equation checked out. Selene still blacked out.&lt;/p&gt;
&lt;p&gt;Projected celestial &lt;code&gt;SceneDepth&lt;/code&gt; was reconstructing a convenient screen proxy, not a physical point on the receiver. The shader then evaluated correct geometry from that fictional position and correctly produced the wrong result.&lt;/p&gt;
&lt;p&gt;Snapping the candidate back to the known sphere before testing occlusion restored the physical contract. The debugging lesson is painfully general: validating parameters and formulas is not enough when the spatial sample itself may be invented.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Why I Stopped Routing Agent Work by Hand</title>
      <link>https://jubaleth.wtf/notes/2026-05/the-user-cannot-be-the-router/</link>
      <pubDate>Tue, 05 May 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-05/the-user-cannot-be-the-router/</guid>
      <category>AI Agents</category>
      <category>Workflow</category>
      <category>Developer Tools</category>
      <description>Copying work between agent windows is routing, state tracking and failure recovery disguised as clipboard use.</description>
      <content:encoded>&lt;p&gt;Using several coding agents exposed a job I had quietly assigned to myself.&lt;/p&gt;
&lt;p&gt;I decided which session needed each result, copied the relevant part into its window, remembered what context it had already seen and noticed when an unanswered request had become stale. If a session died, I inspected the filesystem and reconstructed enough of the handoff for another one.&lt;/p&gt;
&lt;p&gt;That is a router, a delivery ledger and a recovery process. Calling it “copy and paste” only hides the state.&lt;/p&gt;
&lt;p&gt;The first SkyeEngine Code client removes the clipboard part. The larger requirement is to make the routing and delivery state explicit enough that I can supervise it rather than embody it.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Scars Are Currency</title>
      <link>https://jubaleth.wtf/notes/2026-05/scars-are-currency/</link>
      <pubDate>Mon, 04 May 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-05/scars-are-currency/</guid>
      <category>Narrative</category>
      <category>Progression</category>
      <category>Magic</category>
      <description>Damage history, appearance, social consequence and magical power can share one accumulating system instead of four disconnected meters.</description>
      <content:encoded>&lt;p&gt;Scars are intended to accumulate rather than disappear at the next heal. They are vital history, visible character change, something NPCs can react to and a spendable source for magic.&lt;/p&gt;
&lt;p&gt;That overlap is the interesting part. A player choosing power is also choosing what becomes visible and what history remains on the character. A survival consequence can later become capability without ceasing to be a consequence.&lt;/p&gt;
&lt;p&gt;The implementation still needs careful limits—the concept would collapse if scars became just another renewable mana bar—but the durable rule is that one history should drive all four readings.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Per-Client Floating Origins for Planetary Multiplayer</title>
      <link>https://jubaleth.wtf/notes/2026-04/every-client-needs-own-origin/</link>
      <pubDate>Sat, 25 Apr 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-04/every-client-needs-own-origin/</guid>
      <category>Floating Origin</category>
      <category>Multiplayer</category>
      <category>Coordinates</category>
      <description>Local numerical precision is a presentation concern; authoritative celestial placement must survive different client origins.</description>
      <content:encoded>&lt;p&gt;Two players may be standing far enough apart that the best high-precision Unreal origin is different for each of them. Requiring one shared rendering origin would turn a local numerical concern into a global limit.&lt;/p&gt;
&lt;p&gt;The server therefore retains durable placement in the celestial hierarchy while each client represents nearby actors relative to its own origin. Rebasing a client changes coordinates, not the actual game-world location.&lt;/p&gt;
&lt;p&gt;This immediately creates a discipline: replicated and saved values must identify their frame. A raw client-world vector cannot become durable state merely because it currently points at the correct place.&lt;/p&gt;
</content:encoded>
    </item><item>
      <title>Body-Relative Gravity on a Spherical World</title>
      <link>https://jubaleth.wtf/notes/2026-04/gravity-is-not-world-down/</link>
      <pubDate>Fri, 24 Apr 2026 00:00:00 &#43;0000</pubDate>
      <dc:creator>Jubaleth</dc:creator>
      <guid isPermaLink="true">https://jubaleth.wtf/notes/2026-04/gravity-is-not-world-down/</guid>
      <category>Spaceflight</category>
      <category>Gravity</category>
      <category>Coordinates</category>
      <description>On a spherical body, gravity defines a local frame; movement, camera, animation and traces all have to use it.</description>
      <content:encoded>&lt;p&gt;Adding acceleration toward a planet is not enough. The gravity direction defines local down, and the tangent plane derived from it defines the character&amp;rsquo;s usable movement frame.&lt;/p&gt;
&lt;p&gt;Camera yaw must rotate around local up. Movement input must be projected onto the tangent. Floor traces run along local down. Animation speed must ignore velocity normal to the local surface rather than blindly discarding world Z.&lt;/p&gt;
&lt;p&gt;The decision was to make the gravity-local basis an explicit shared input. Every system deriving its own approximation would eventually disagree at a pole, during a transition or after a floating-origin rebase.&lt;/p&gt;
</content:encoded>
    </item>
  </channel>
</rss>
