# HouseFly 🪰 [![Validate](https://github.com/Vortitron/HouseFly/actions/workflows/validate.yml/badge.svg)](https://github.com/Vortitron/HouseFly/actions/workflows/validate.yml) [![hacs](https://img.shields.io/badge/HACS-custom-41BDF5.svg)](https://hacs.xyz) [![project page](https://img.shields.io/badge/write--up-housefly.vome.io-5ad7ff.svg)](https://housefly.vome.io) **A fruit fly lives in your Home Assistant. Its brain is the real one.** HouseFly runs a rate model of 4,724 identified *Drosophila melanogaster* neurons wired together by 126,108 measured synaptic connections, taken from the Janelia hemibrain reconstruction and the FlyWire whole-brain dataset. Your sensors are delivered to the neurons that carry that kind of information in the animal. Its motor output moves a fly across your dashboard. It learns which parts of your house it likes. **All of it runs on your own machine.** The brain is 428 KB of connectome on your disk and numpy on your Home Assistant host -- 59 ms of CPU per two-second tick, about 3% of one core on a 2018 desktop i7, no GPU and no inference service anywhere. The pack ships inside the integration; on installs where the files arrive by a route that cannot carry binary, it is fetched from the release tag and checked against a compiled-in SHA-256 before anything loads it. The only part that runs elsewhere is the eye, which runs in the browser tab because that is where the frames are, and sends back two numbers. [The project page has a diagram of the whole arrangement.](https://housefly.vome.io/#where) That figure is from one desktop, with one fly. The second measurement came from the Swarm, a hosted virtual machine running four flies at once: 136–153 ms per fly per tick, about 7% of a core each and nearly 30% for the house — more than twice the desktop's figure. It is the machine, not the company: the demo box, the same kind of virtual machine with two flies, reads 135–137 ms each. Nothing has yet been measured on a Raspberry Pi or a Home Assistant Green, which is where most houses run, and a small ARM board is likely to need several times as much again: plan on more than one fly being a real load there until someone measures it. Every fly reports what it costs on its own host as a **Tick cost** diagnostic sensor, whose `share_of_one_core` attribute is the number to watch. Up to three seconds, a longer tick interval does not make it cheaper, only less often: each tick simulates its whole interval, so the cost per second stays the same. Beyond three seconds a tick simulates at most three, so a six-second interval roughly halves the cost and the brain runs at half speed (its day still follows the house's clock). `python3 tools/bench.py` measures the cost with nothing but numpy, before you install anything, and prints what it would be at each interval. Numbers from small boards are very welcome as issues. ## What you get - **`binary_sensor._something_unusual`** — the useful part. It learns what your house normally looks like, hour by hour, with no rules and no training, and turns on when the house stops looking like that. It stays quiet for its first day, and for the first ten minutes after Home Assistant starts. Its attributes say what changed just before and where the surprise is arriving from. It cannot say what is wrong, and what it can hear depends on how much it watches: a whole-house fly hears the house changing shape, not one switch; see [What it hears](#what-it-hears). - **A blueprint that turns that into a notification**, dismissed when the house settles, with room for your own actions (a phone notification, say): [![Import the blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2FVortitron%2FHouseFly%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fhousefly%2Funusual_notify.yaml) - **A `fly_house_unusual` event** on the same rising edge, for anything that should run only when something has changed, such as asking a language model to go and look. - **A fly that walks over your dashboard** and lands on your cards, and a card showing the connectome with live activity in it. - **Sensors** for what the fly is doing (mode, arousal, hunger, familiarity), and **Approach**: the rate at which something is closing in on a ranging sensor, which is useful in its own right for a path light. - **It touches nothing** until you turn actuation on, and even then only what you list, with locks, alarms, covers and climate refused outright. See [Safety](#safety). Several flies can share a house, each with its own name, clock and senses. --- ## The one rule There is a tempting way to build something like this, and naming it is worth more than any other design note here, because avoiding it is the only decision that matters. You take a recurrent network, seed it with random weights, call the shape "fly-inspired", and hang the behaviour you actually want off the side of it in plain `if` statements. It looks alive. The network could be deleted without changing a single thing the user sees. So the rule is: **every behaviour comes out of the measured wiring, or it is labelled as not coming out of it.** There is no third option, and the label is not a consolation prize — goal seeking, wall avoidance and estimating dawn from the light are all controllers, they all say so in the code, and the model is better for the line being drawn rather than blurred. That rule is why this file is mostly numbers, and why some of them are failures. The ring attractor is not asserted, it is measured out of the synapse counts. The escape threshold is not a constant someone chose, it is where the network sits. And where the model turned out not to do what it appeared to do, that is written down with the measurement that caught it: - The compass responds to turns but does not **integrate** a sustained one. The check that passed was only ever testing a *change* in the command. - The escape pathway had no real threshold: 1e-5 and 1.25 of looming produced the same burst, so θ̇ = v/r² was computed faithfully upstream and then thrown away by the network. - A directed escape is impossible with this data pack, because the hemibrain is a *hemi*brain and there is no left LPLC2 population to compare against. Each of those is now a check that fails if it is ever quietly fixed or quietly broken. A model that always agrees with its own documentation is the failure mode, not the goal. --- ## What it does instead Replace the random matrix with circuits that have a known function, and give each one the input it actually carries. | Circuit | What it does in the animal | What it gets from your house | |---|---|---| | **EPG / PEN / PEG / Δ7** | Ring attractor holding heading | Integrates the fly's own turns into a compass | | **ER ring neurons** | Visual bearing into the ellipsoid body | Bearing to every card on your dashboard | | **PFL3** | Compares heading against goal, commands a turn | Steers it towards whatever it currently wants | | **Kenyon cells → MBONs** | Associative memory, with real plasticity | Learns which parts of your house are good | | **PAM / PPL1 dopaminergic** | Reward and punishment teaching signals | `fly_house.feed` and swatting at it | | **LPLC2 / LC4 → DNp09/10** | Looming detection → escape | Ranging sensors closing on it; motion; your cursor | | **Projection neurons** | Odour identity | The state of your house as a smell | | **s-LNv / LNd / DN1** | Morning and evening circadian oscillators | Real local time | Everything else follows from those. There is no rule anywhere that says "be active at dawn" — the morning cells are driven by morning, and arousal is read off their firing rate. --- ## The parts that are genuinely interesting ### The ring attractor is in the wiring, not in the code Nobody wrote a ring attractor. The connectome contains one. Bin the effective EPG → Δ7 → EPG coupling by heading offset, using only measured synapse counts and the bridge-glomerulus labels: ``` 0 deg 89 # 45 deg 181 ### 90 deg 919 ################# 135 deg 1791 ################################# 180 deg 2025 ##################################### 225 deg 1827 ################################## 270 deg 921 ################# 315 deg 208 ### ``` Δ7 is inhibitory, so this is local excitation with long-range inhibition — a Mexican hat, 23:1 — and that is exactly and only what a ring attractor needs. Run `python3 tools/build_connectome.py` and it prints this from the raw data. This also caught a mistake. Originally both halves of the protocerebral bridge were given the same angular order, which produced a much weaker 2.7:1 profile and a compass that held a heading perfectly well but would only ever turn one way. The real bridge is a *mirror-symmetric* double map. Correcting it sharpened the signature to 23:1 — the data told us the anatomy was wrong before any simulation ran. ### The direction of turning is measured, not assumed Which way does exciting one hemisphere's PEN cells push the bump? You can read it straight out of the connectome: ``` EPG -> LEFT PEN1 -> EPG loop shift: +12.5 deg EPG -> RIGHT PEN1 -> EPG loop shift: -11.5 deg EPG -> LEFT PEG -> EPG loop shift: -5.6 deg EPG -> RIGHT PEG -> EPG loop shift: +0.9 deg ``` PEN loops shift the bump in opposite directions per hemisphere; PEG loops don't shift it at all. That is the textbook split between the loop that *moves* the bump and the loop that *holds* it, and it fell out of the synapse counts. ### Approach, measured properly LPLC2 fires at an object *expanding* in the visual field. For a target of size L at range r closing at speed v the angular size is θ ≈ L/r, so the expansion rate is **θ̇ = L·v/r²**. That r² is the whole character of the response: the same footsteps count for far more at one metre than at five, which is why a real fly leaves it so late and then goes all at once. A ranging sensor — mmWave radar, ultrasonic, BLE distance — gives r directly and v by differencing, so it delivers exactly the quantity the circuit is built for, by radar instead of by photons. Difference against the sensor's *own* timestamps, not against the times you happened to look at it: a tick that straddles a reading sees a whole sampling interval of movement but only one tick of elapsed time, and the velocity comes out inflated by whatever that ratio happens to be. On the demo box, a 5 s sensor read on a 2 s tick fired escape at 4.0 m and then not at 3.1 m or 2.2 m — an ordering that is not distance at all, it is sampling jitter. Point HouseFly at one and someone walking up to the door produces a genuine looming response: Someone walking in at 1 m/s, sampled at a range of distances: ``` 5.0 m closing θ̇ 0.040 escape 0.0000 3.0 m closing θ̇ 0.111 escape 0.0000 2.2 m closing θ̇ 0.207 escape 0.2714 2.0 m closing θ̇ 0.250 escape 0.2893 1.4 m closing θ̇ 0.510 escape 0.2627 0.6 m closing θ̇ 2.778 escape 0.2725 ``` Walking away produces nothing, because receding is not looming. Nor does something *appearing*. An expansion rate is the growth of an image the fly was already looking at, so both ends of a step have to be in range. This matters more than it sounds: a real front-path radar reporting a fused "position" parks on sentinels when nobody is there (0, or the far end of the path) and flicks between targets several times a second, so 14.9 m followed by 1.1 m read as 13.8 m of closing in a second. Replayed through the coordinator at its real 2 s tick, 7.4 hours of that radar gave **27 escape episodes an hour** judging only the new reading, and **0.8 an hour** requiring both — while a walk up the path in the radar's 0.75 m gates still ramps 0.03 → 0.25 → 0.29 as it nears. (Check 6c runs both rules on both traces.) A fused position is still a poorer input than a per-radar distance; this only stops it lying. **Do not also feed it the presence sensor.** A PIR going high carries no distance at all, so HouseFly delivers it as a flat startle — which is honest for a PIR and above the escape threshold by design. But a presence binary sensor *derived from* a ranging sensor is the same event arriving twice, and the boolean gets there first: on the demo box the fly bolted at 3.5 m, on the occupancy flag, several seconds before the graded pathway had anything to say. Feed HouseFly the range and leave the flag out, or the r² is decorative again for a different reason. Both radar sensors in `packages/demo_radar.yaml` are still wired up; only `binary_sensor.radar_presence` is kept out of `input_entities`. Note the shape: nothing at all until about 2.4 m, then the same full burst at every distance inside it. That is not a rounding artefact, it is the pathway — the giant-fibre escape is a trigger, so the interesting quantity is *where* the threshold falls, and the r² is what puts it there. Halve the walking speed and the fly lets you get to 1.7 m instead; the response itself does not get smaller. This took a correction to get right. The resting drive that stands in for the parts of the brain not modelled was applied to every neuron, including this pathway — and since that drive and the firing threshold are the same number, it parked the escape circuit exactly at threshold, where a looming input of 1e-5 fired precisely the same burst as one of 1.25. The r² was computed faithfully upstream and then thrown away. DNp09 and its LPLC2 inputs are silent at rest in the animal, which is what makes them a trigger at all, so the fix was to withhold the resting drive from the circuits already marked phasic. `tools/validate.py` now measures the onset response across four decades, which is the check that would have caught it. ### It has an eye now The ranging-sensor path is honest but it is not how the animal does it: a fly computes expansion from photons, in the optic lobe, before anything central hears about it. `housefly-eye-card` is that stage, and it runs in the browser because that is where the frames are. It is the published circuit rather than a motion detector: photoreceptors on a 32×24 grid (a *Drosophila* eye has about 750 ommatidia at ~5°, so that number is not an approximation), a lamina high-pass, Hassenstein–Reichardt correlators giving the four direction-selective layers of the lobula plate, and then 63 LPLC2 cells tiling the field. The LPLC2 stage is the one that matters. Klapoetke et al. (2017) showed each cell has four dendritic branches, one per layer, each offset so it reads motion pointing *away* from that cell's receptive-field centre — and that the cell needs all four driven at once. That coincidence requirement is the whole of the selectivity, and getting it wrong is instructive: summing the four branches instead scored a drifting grating at **nine times** an actual approach and a disc crossing the view at **thirty times**, because one large branch carried the sum on its own. A geometric mean cannot be carried by one term. With it, those two stimuli score exactly zero. Measured in `tools/test_vision.mjs`, all against the same approach: ``` approach, plain background 3.121 fires approach, cluttered room 0.124 fires, 25x weaker distant approach (6.0-4.0 m) 0.047 below threshold receding 0.000 drifting grating 0.000 object crossing laterally 0.000 full-field flicker 0.000 camera panning across a room 0.000 hand-held camera wandering 0.003 ``` Two limits worth stating. **Clutter costs it 25×** — real LPLC2 is also less sensitive in a textured scene, but it means this works far better against a plain wall than against a bookshelf. And **a moving camera is suppressed entirely**: the field's flow coherence is 0.00 for an approach and 0.76–0.84 for a pan, so a self-motion gate sits in the empty gap between them. That is what a fly does during a saccade, and it has the same consequence — an approach that happens while the camera is panning is missed. Nothing leaves the page. Home Assistant receives an expansion rate and an angle about ten times a second, which is roughly what a real LPLC2 population sends down its axons: a magnitude and a retinotopic address, not a picture. **Frame rate is what kills most cameras.** Optic flow needs frames close enough together to correspond. The first install this was tried on had one camera, a traffic camera whose own `photo_time` showed it updating *every five minutes* — at that spacing there is no correspondence between frames at all, so flow would be noise and a looming detector fed from it would fire constantly and mean nothing. **A directed escape is not possible with this data pack, and that is worth saying.** LPLC2 is retinotopic, so in the animal the population says where the threat is and the fly turns away from it. Ours cannot: the hemibrain is a *hemi*brain, and the looming pathway in the pack is 0 left / 130 right / 147 unlabelled, with 1 left and 12 right descending neurons. There is no left LPLC2 population to compare against. The central complex is balanced 23/23, 21/21 and 12/12, so the compass and steering are unaffected — but the escape burst itself stays undirected, which is at least true to DNp09. `tools/validate.py` checks those counts, so the day someone builds a pack from a whole-brain reconstruction, the check fails and this paragraph becomes wrong on purpose. ### What it is *for* The honest answer is not "it saves you power". A fly has no concept of your electricity bill, and bolting one on would be a rules engine in a fly costume. But the mushroom body — the largest thing modelled here — does compute something a house genuinely wants, and it is the thing rules engines are worst at. **It tells familiar from unfamiliar.** Dasgupta, Stevens & Navlakha (2017, *Science*) showed the Kenyon cell layer is a locality-sensitive hash: a sparse random projection whose codes stay close for similar inputs and far apart for different ones, which is an efficient novelty detector and was published as one. Hattori et al. (2017, *Cell*) found the readout — repeated exposure depresses KC→MBON synapses in the α′3 compartment whether or not anything good or bad happened, so those cells fire hard for something new and barely at all for something met many times. Both are in the pack: α′3 is MBON16, MBON17 and MBON28, and none of them is one of the approach/avoid cells, so familiarity cannot be confused with valence. ``` first sight of a pattern novelty 0.976 after 400 ticks of it novelty 0.027 a pattern never met before novelty 0.186 the familiar one, for contrast novelty 0.033 ``` **The useful shape of this is as a trigger, not an answer.** A sparse hash ticking twice a second costs nothing; asking a language model to go and look at the whole house costs real money. So let the fly decide when it is worth asking. HouseFly fires a `fly_house_unusual` event on the rising edge, carrying the novelty figure, how long it has held, and a shortlist of **suspects** — which configured inputs the surprise is arriving through, obtained by reading the measured PN→KC wiring backwards from the cells that are active and have not habituated. Not "what is wrong": the Kenyon code is a hash and a hash does not invert, and channels collide because a random projection with more entities than glomeruli must. Each channel's share is weighted by what is actually arriving on it, so a channel nothing feeds is never blamed — before that, on a fly watching twelve entities, three of the top four "suspects" were channels carrying nothing, and alerts named nobody. Measured on a house where four new things switch on, 49% of the share lands on those four, against about 3% by chance. **What it cannot do, and this stays written down: name something that went away.** A trace that runs backwards through current activity has nothing to follow on a channel whose input just switched off — 0% of the share in every trial. So the event also carries `recently_changed`: the entities it watches that changed state in the last ten minutes, most recent first. That is the coordinator's record, not the mushroom body's inference, and it is labelled as such. Between them they narrow the field, which is the right job for something whose next step is to hand the question to a system that can go and look. **It will not raise the alert in its first day.** The hour is part of what the fly knows, so having found the house familiar at four in the afternoon says nothing about what it looks like at dusk. Every marginal alert seen on a live house fell inside a fly's first day of learning — one of them at 18:44, which is when the sun goes down there. A fly must have lived one whole day since it last started learning (a new install, or a change to what it watches) before it may call anything unusual; it says "still learning what a whole day here looks like" until then. A restart does not count as starting again: its familiarity is saved. **Nor while Home Assistant is starting up.** A house that is booting is not itself: entities come back unavailable and fill in over a minute or several, and every one of them is stamped as having just changed. With their memories intact, four flies read 0.024, 0.078, 0.389 and 0.588 on the first tick after one restart — the whole-house one hardest — and the 0.389 one raised the alert before it had settled. So for the first ten minutes after a start the fly keeps learning but passes no judgement, and changes stamped in that window are not reported as changes. `binary_sensor.housefly_unusual` is that, made slow enough to be worth saying: it stays silent until it has actually learned something, then wants two minutes of strangeness, on average, before it speaks. **No training set, no labels, no cloud** — it learns what your house is like by living in it. And it will never tell you *what* changed, only that something has, which is precisely the thing a rule cannot do, because you would have had to write the rule first. ### What it hears For most of its life it heard almost nothing, and the way that was found is worth more than the fix. A quiet alert proves nothing on a simulated house, where nothing is ever unusual, so the Swarm had things planted in it — once a day at no set hour, the garage door motor running, every light on at once, or somebody walking round a house nobody walks round — and the alerts were scored against them. Six of the first seven went unheard. When the garage motor ran for the first time in its life, the whole-house fly's familiarity went from 97.95% to 97.6%. Traced back, the antennal lobe was latching. Its measured PN→PN synapses held about 40 projection neurons up with nothing arriving, so once a smell had lit it, it stayed lit, and the Kenyon cells hashed whatever it had first fallen into rather than the house. One smell and another came out 36% alike; the same smell a minute later, 36% alike too. Real antennal lobes are kept out of that state by local interneurons this data pack does not have, so the PNs now carry a static gain, the same kind the Kenyon cells and the fan-shaped body already needed: past it the lobe lets go when the smell does, the same smell makes the same code (100% alike) and another smell another (5%). Two things had been tuned against the latched lobe and moved with it: the Kenyon cells' own gain, which now starved the code and the valence memory below, and the clock, whose channels are PNs and had been turned down by the same gain (the numbers for both are in `circuits.py`). And 22 of the 123 odour channels turned out to reach no Kenyon cell at all — real PNs that go only to the lateral horn — so about one entity in five had been invisible to familiarity however it behaved. Entities are now hashed only onto channels that reach the mushroom body. The alert also changed what it waits for. It used to want two unbroken minutes above the line, and novelty is *meant* to fade with exposure — that is the habituation the whole thing rests on — so a never-seen event that peaked at 0.27 had faded to 0.05 by the end of its second minute and was thrown away. It now judges the average over those two minutes, which still ignores a twitch. What a fly hears after that, measured over three houses each, with the change held until it either speaks or doesn't (`tools/validate.py` section 16 pins one of each): ``` 6 watched 12 watched 113 watched four things at once 3 of 3 3 of 3 1 of 3 one device 1 of 3 1 of 3 0 of 3 ``` **A small fly hears a lot; a whole-house fly hears only big changes, and only some of them** — four things switching at once, one house in three (a second came to 0.095 against a line of 0.10), and one switch never. The last is not a fault: a locality-sensitive hash is built to call one change among a hundred the same house. The Kenyon gain that would let it hear more (3.0 heard all three) is the one that starves the valence memory, and this keeps the memory. One device in a small fly is heard when the projection neuron its channel lands on answers strongly, and those vary — driven at full strength they reach anywhere from 0.02 to 0.5, a quarter of them under 0.2. So a device you care about belongs in a small fly, and even there it is a matter of luck which channel it gets. Whether this holds up on a house that is lived in is what the Swarm is now being scored for again. One implementation note, because it is a limit of the data rather than a choice. Habituation is held per Kenyon cell, not per synapse: the pack reconstructs 623 of the KC→α′3 synapses out of 20,391 KC→MBON synapses, from 330 of 1,927 Kenyon cells. With about 25 cells active at a time that is roughly four synapses carrying the readout — measured, it sat at exactly zero for a hundred ticks and then jumped to 0.999. A presynaptic trace is well sampled, it is the same claim, and it still drives the network through the α′3 edges. ### Landing, not passing overhead The fly changes something by standing on it. That used to be enforced with a speed threshold — act only below 0.30 — and the threshold could never be met while the fly was awake. Forward speed here is essentially proportional to arousal, so the two conditions were mutually exclusive: ``` local time arousal speed actuation gate (speed <= 0.30) 02:00 0.166 0.135 passes, but asleep -- barred on mode 06:00 0.830 0.674 awake, and barred on speed 09:00 0.185 0.152 passes, but asleep 19:00 0.880 0.717 awake, and barred on speed ``` Every state slow enough to pass was asleep and rejected for that instead. The path could not fire at any hour of any day, and had not: nineteen hours on the demo gave zero actuations *and* zero blocks, the second number being the giveaway, since nothing had even reached the safety layer to be refused. Dwell says what the speed gate was trying to say. It has to stay on one thing for a few seconds rather than pass over it, which is what landing means, and it works at any arousal. `tools/validate.py` now asserts that nothing gates actuation on speed, because that particular mistake is invisible from the outside — a feature that never fires looks exactly like a fly that never felt like it. ### It works with nobody watching The fly lands on *cards* to change things, and cards only exist while a browser has the dashboard open. So with nobody looking there was no layout, and the actuation gate returned early every tick. Measured on the demo: **zero actuations in eight hours**, with nothing even reaching the safety layer to be refused. Correct by the letter of the code and useless. An integration that only does anything while somebody watches it is a screensaver. With no dashboard open, the entities it may touch are now laid out on a notional grid and it walks among those instead. The rules on top are unchanged: it still has to settle, still has to be standing on one, the governor still has to agree. A real dashboard always wins when there is one, because then the fly is walking on things you can actually see. An observe-only install with no outputs gets no phantom furniture. ### More than one fly A house can hold several. Each is its own config entry with its own name, its own brain, its own memory on disk, and its own view of the house, so they are genuinely separate animals rather than one animal drawn three times. Three reasons to bother, in rough order of how much they buy you: **Separate territories.** The antennal lobe has 131 glomeruli. Sixty-two entities hashed into 123 odour channels collide; thirty-one collide far less, so two flies watching half the house each have sharper noses for their own half than one fly has for all of it. **Different timescales.** Habituation has one recovery rate. A fly that forgets quickly notices the brief and the odd; one that forgets slowly notices drift. Running both gives you an ensemble that sees either. **Shifts** — and the number here is not the obvious one. A fly is crepuscular: awake around its own dawn and dusk, asleep at its subjective midday and midnight. So the tempting move is to put one fly twelve hours out and call it the night shift. That is the one offset that does nothing, because the peaks are already about twelve hours apart, so twelve maps morning onto evening: ``` wall +0h +6h +12h 00:00 sleep WALK sleep 06:00 WALK sleep WALK 12:00 sleep WALK sleep 18:00 WALK sleep WALK ``` Six is complementary. Twelve is very nearly a copy, and `tools/validate.py` checks both, because it is exactly the sort of thing that reads as obviously right and is obviously wrong. A sleeping fly cannot act — the actuation gate refuses on mode — so shifts also partition *who touches what and when* without any coordination between them. **Every fly learns the same dawn.** Where this house's day is gets learned from the light (see below), and it is learned on the *house's* clock, not the fly's. Dawn is a fact about the sky; a fly's shift is its fixed phase angle to it — a nocturnal animal and a diurnal one entrain to the same sunrise and simply sit differently against it. This was once done the other way, in each fly's own shifted clock, which is self-consistent and quietly fatal: a +6 h fly sees the 06:49 sunrise at its subjective 12:49, learns *that*, and ends up peaking at 06:49 on the wall alongside the fly it was meant to cover for. On a four-fly house the shifted flies' learned dawns had drifted three and a half hours in two days. `tools/validate.py` simulates a month of sunrises both ways: the old frame leaves the two flies 0.0 h apart, the fixed one 6.0 h. Sleep is a bout, not a comparison. The fly counts as asleep after five minutes of quiescence and wakes as soon as it is roused, which is the standard criterion in the fly sleep literature and is also what makes `binary_sensor.*_awake` worth triggering on. It used to be a bare threshold on arousal, and because arousal is continuous and sits near the line for long stretches, the sensor chattered: measured overnight on a live house, one fly's went on and off eight times in eighty seconds. Waking is not instant, because the arousal cells integrate over minutes — turning a light on gets it up in well under a bout, not on the tick. The clock says *when* a fly would like to sleep; how long it has been up says how badly. That second drive is real fly biology — R5 ellipsoid-body neurons potentiate with time awake (Liu et al. 2016), and it is why a deprived fly sleeps more afterwards — and without it a fly whose clock a busy house held up simply never slept. The whole-house Watcher, smelling 101 entities, did not sleep once in a week; its night-time arousal never fell below 0.50 against a line at 0.34. The demo box's main fly was the same at 0.46. Sleep pressure now builds while the fly is up (over about 18 h) and clears while it sleeps (about 4 h), and raises the line as it builds. Replaying a real day of each live fly's arousal through it, the Watcher sleeps 12% of the day in about a dozen broken bouts, a fly in a noisy room, while the quiet flies move only a few points. The time constants are borrowed from the two-process model of human sleep; the shape — slow to build, quick to clear — is the claim, not the numbers. **Hunger is discharged by eating.** It rises while the fly is up and, more slowly, while it sleeps. A foraging fly that lands on something starts a meal, eats until it is full rather than just under the forage line, and stops if it leaves, sleeps or bolts. Food is wherever its walking takes it — not a claim that your lights are edible, but that its meals come from behaviour rather than a timetable. An earlier version let sleep discharge hunger at rates balanced for a fly awake three hours in eight. The live flies were awake nearer two in three, and a week later all of them were pinned at 95–100%: foraging almost every waking minute, walking 4% of the time, grooming never. A starving fly also parks on its favourite light for ever, and since a landing earns one decision, the demo fly stopped acting entirely. A hungry fly's meal is also delivered as a sugar reward to its PAM neurons — which is why appetitive conditioning starts by starving the flies — and since 2.16 it lasts the whole meal, so the fly learns what the house is like when it eats (see *It has a memory*). Checks 14b and 14c run a week of both the old and new hunger rules. **How they interact:** through the house, and nothing else. One fly turns a light on and the others smell it. Point one fly's `binary_sensor.*_escaping` at another's inputs and a startled fly startles its neighbours. Both are ordinary configuration and need no new mechanism. There is no direct coupling between their brains, and there will not be: there is no connectome for fly-to-fly, and inventing one would be the rules-engine-in-a-costume failure this project exists to avoid. ### Watching is free; touching is not These are two different questions and the setup asks them separately. **What it can sense** is read-only and cannot break anything, so there is a *Watch the whole house* switch that hands it everything — sensors, binary sensors, lights, switches, climate, covers, presence — up to 250 entities. That buys breadth and costs resolution: a fly watching the whole house notices the house as a whole looking different, and stops noticing any one thing in it (see [What it hears](#what-it-hears)). A fly that watches six things notices those six far better. Several flies can share a house, so it does not have to be one or the other. Automations, scripts, scenes, updates and notifications are excluded on purpose. They change when *you* change the system rather than when the house changes, so including them would make every upgrade look like an intruder. So are HouseFly's own entities, which would otherwise have it smelling itself thinking. **Sweeping the house does not make it startle at everything.** A motion or occupancy flag arrives as a flat startle, which is over the escape threshold on its own — that is honest for a PIR, which carries no distance, and it is why a presence sensor *derived* from a ranging sensor has to be kept out of the inputs. Whole-house mode would have swept those straight back in, so the startle path reads only entities somebody listed by hand. Listing a motion sensor says "startle the fly with this"; sweeping the house says nothing of the kind. The demo found this the hard way: the sweep pulled in a radar-presence sensor and four simulated-occupancy sensors, and the fly bolted three times per ninety-second cycle all night instead of once per approach. **What it may touch** stays exactly what it was: a short, explicit list, capped at 16, restricted to a handful of domains, vetted by name, rate-limited, and off entirely until you turn it on. Locks, alarms, covers, climate, water heaters and valves are refused outright whatever anyone configures. The entity-to-glomerulus assignment is a hash of the name, so whole-house mode sorts the list before using it — otherwise the order Home Assistant happened to return things in would decide which room smells like which, and the mushroom body would relearn the house on every restart. Past 131 entities they begin to share glomeruli and two rooms start smelling alike; `tools/validate.py` measures that collision rate rather than assuming it away. ### It knows when this house's day is The clock used to peak at a fixed 06:00 and 18:43, which is nobody's daylight and at this latitude is not close for most of the year. It now watches whatever light the house reports — an illuminance sensor if there is one, `sun.sun`'s elevation otherwise — learns where dawn and dusk actually fall, and moves the morning and evening oscillators there. ``` 02 04 06 08 12 16 20 22 23 textbook 06:00/18:43 0.17 0.33 [0.83] 0.33 0.17 0.30 [0.68] 0.22 0.17 summer, dawn 04:00 0.31 [0.73] 0.43 0.20 0.17 0.21 0.52 [0.76] 0.64 winter, 08:30 to 15:30 0.17 0.20 0.35 [0.70] 0.20 [0.75] 0.31 0.18 0.17 ``` In the short day the two peaks close up to seven hours apart, which is what the morning and evening oscillators do in a real fly (Rieger et al. 2003; Stoleru et al. 2007). **This is photoperiod tracking, not entrainment, and the difference is worth being exact about.** Entrainment is a free-running oscillator being pulled into phase by a zeitgeber. There is no free-running oscillator here — the clock in this model is a function of local time — so moving where the peaks sit is the honest version of the claim, and estimating dawn from the light is done in the coordinator and labelled a stand-in rather than dressed up as neural. Separately and acutely, light drives **l-LNv**, which are the arousal-promoting clock cells and are genuinely light-responsive (Shang et al. 2008). That is why a light switched on at three in the morning rouses a fly the clock says should be asleep. The photoperiod shifts *when* it is active; this makes it active *now*. ### It has a memory, and the memory is synapses Learning in *Drosophila* happens at Kenyon cell → MBON synapses: a Kenyon cell active at the same moment as a dopaminergic neuron in that MBON's compartment gets **depressed**. That is the whole rule, it is anti-Hebbian, and it is why a fly stops approaching things that turned out to be bad. HouseFly implements exactly that, on 20,391 real KC→MBON connections, and each MBON's compartment valence is derived from which dopaminergic class dominates its input rather than hard-coded. Those gains are what gets persisted across restarts — the fly does not forget your house when Home Assistant updates. And it reaches behaviour, which until 2.16 it did not ([#4](https://github.com/Vortitron/HouseFly/issues/4)). Measured on the synapses alone — learned gains handed to a brain that has never been rewarded, against an identical naive one: ``` smell paired with sugar +0.13 valence smell paired with punishment -0.09 at another hour, beyond the general shift: the paired smell +0.092 an unpaired one +0.001 half-life with nothing more to learn about 45 minutes what a reward leaves that is not in the synapses nothing (0.000) ``` Getting there took four things, each a fault rather than a tuning choice: - **Dopamine is not a fast synapse.** The build discarded "dopamine" as a transmitter label, so all 317 PAM and PPL1 neurons fell through to the cholinergic default and drove their targets directly — the opposite of the build's own comment. That made the output network a flip-flop: one reward set valence to +0.39 and held it, with no synapse changed, for up to fifteen hours on the live Swarm. They now act only through the learning rule. - **MBONs and APL read a dense code, and are normalised as such.** Each cell's excitatory and inhibitory input are scaled to separate budgets, which the compass needs. But a Kenyon-cell-fed MBON gets 4.7% of its synapses from inhibitory cells, and APL 0.9%, and the split budget weighted that sliver at five times everything else: the MBONs became winner-take-all, and a silent MBON cannot express anything learned. They share one budget now, inhibition still five times stronger per synapse. - **Only MBONs with Kenyon input are read.** The hemibrain reconstructs the right mushroom body; 18 of 65 MBONs have their dendrites outside it, receive no Kenyon input, and were half the readout. The 47 that remain split 22 approach, 25 avoid. - **The sign.** MBONs in reward (PAM) compartments drive *avoidance* and those in punishment (PPL1) compartments drive attraction (Aso et al. 2014b) — which is why depressing the former is how a reward makes something more attractive. The readout had it backwards. Sugar now counts for as long as it is in the fly's mouth — the whole meal, or thirty seconds of `fly_house.feed` — because a single instant taught a twentieth of what a real conditioning trial does. Section 5d of the checks measures every line of the table above. ### What the connectome does not contain Two things were expected to fall out of the wiring and did not. Both are worth stating plainly, because "we tried to derive it and could not" is a result. **PFL3's steering computation.** In the animal, PFL3 compares the heading bump against a goal and its left-right imbalance is the turn command. That works because each cell's fan-shaped-body arbor sits about a quarter turn from its bridge arbor, in opposite directions per hemisphere. We tried it both ways -- feeding the goal into the fan-shaped body and letting the connectome do the rest, and imposing the quarter-turn offset on PFL3 explicitly. Neither gave reliable goal-following: the correlation between turn command and the sine of the heading error ranged from +0.03 to -0.54 depending on the trajectory, and the closed loop never converged. That offset is *where the arbors physically sit*, and synapse counts between cell types do not carry it. So the goal-seeking controller lives in `coordinator.py`, four lines of proportional control, labelled as a controller. The fly holds a heading using a connectome-derived ring attractor, which is real, and chooses which heading to hold using arithmetic, which is not. Keeping those visibly separate matters more than having one more thing to claim. ### The map of your house is *not* in the connectome either The original plan was to read the landmark-bearing → heading mapping off the ER → EPG connectivity. It isn't there: that projection is near-uniform, with about 7% modulation depth and no consistent phase. This is not a gap in the data. In the real animal that map is **learned**, in plastic ER→EPG synapses (Fisher et al. 2019; Kim et al. 2019) — a naive fly does not have one. So HouseFly doesn't fake it. The compass is driven by the fly's own turns, exactly as a real one is in the dark, and it drifts when it cannot see. **It does not integrate a sustained turn, though, and that is a real limit.** Hold the angular-velocity command at a constant value and the bump shifts once, by a few tens of degrees, and then stops: a constant −1.5 rad/s moves it 43° and holds. It is a position offset, not a velocity. The cause is in how the PEN cells are driven here — angular velocity is injected into one hemisphere's PENs additively, which displaces the bump to a new equilibrium, whereas in the animal PEN firing is the *conjunction* of heading and turning (Turner-Evans et al. 2017; Green et al. 2017) and that multiplication is what makes the loop integrate. Multiplicative gating was tried across PEN gains from 2 to 40 and did not recover it either, so this needs more than a constant. What works is the transient: a change in commanded turn rotates the bump, in the right direction, by an amount that tracks the command. That is what `tools/validate.py` measures, and the wording there used to say "integrates", which was reading more into the result than was in it. --- ## Honest limits This is a real model of real circuits, and it is still a model. - **4,724 neurons of about 25,000** in the central brain, and no body, no muscles, no proprioception. Most of the input these cells really receive comes from neurons that are not here at all, which is why there is a tonic drive term standing in for the rest of the brain. That term is an assumption. - **A rate model, not spikes.** One leaky rate unit per neuron, following the approach of Shiu et al. 2024. No dendrites, no delays, no channel dynamics. - **Synaptic sign comes from predicted transmitter.** Mostly reliable, but FlyWire's classifier returns acetylcholine for most ring neurons, which is wrong — they are GABAergic. Where the literature is settled it overrides the prediction, and every such override is listed in `tools/build_connectome.py`. - **What the valence memory learns is partly the hour.** Kenyon cells that fire whatever the smell are in every code, so a reward also shifts everything a little, and the hour it happened at most. That is real information ("good things happen around now"), but it means an unpaired smell at the same hour moves about two-thirds as much as the paired one; only at another hour does the smell itself clearly dominate. - **Half the MBONs are only half there.** The 18 whose dendrites lie outside the hemibrain still fire, driven by other MBONs, and still send their outputs on; they just cannot learn, and are left out of the valence readout. - **The compass needs to have moved once.** Its bump holds indefinitely, but from absolute stillness it used to ignite only because one ring neuron (ExR7) sat a hair above threshold. With the MBONs firing as they should, MBON11 holds it a hair below. `settle()` ends with a few seconds of turning, which any animal has had before anyone asks it which way it faces; check 4 pins the rest as a known limitation. - **Roughly six free parameters** — global gains, time constants, an excitation/inhibition ratio — tuned so the network sits in a regime where the bump is stable. The *connectivity* is untouched; the operating point is not derived from anything. - **Two brains, spliced.** Connectivity is hemibrain (a female fly's central brain); positions and transmitters are FlyWire (a different female fly). Joined by cell type, which is standard practice and still an approximation. - **The compass responds to turns but does not integrate them.** The response to a *change* in angular velocity is monotonic and correctly signed over roughly ±2 rad/s; the response to a *sustained* one is a fixed offset rather than continuous rotation. Measured and checked both ways, so the failure is visible rather than implied. - **Three circuits needed a static gain constant** (`CIRCUIT_GAIN` in `circuits.py`). The fan-shaped body and the Kenyon cells are large and almost entirely excitatory with no matching inhibitory population inside the modelled subnetwork, so without it they flood; the projection neurons, without the antennal lobe's local interneurons, latched (see [What it hears](#what-it-hears)). An attempt to have every circuit regulate its own gain homeostatically fixed that but destabilised the compass, and is written up in the changelog rather than quietly dropped. - **Adaptation is applied to two circuits, not the network.** Without it the looming and descending pathways latch and the fly flees permanently after one startle; with it applied everywhere, the compass bump decays. - **Sensory channels adapt to their own observed range**, so a channel that has only ever seen a narrow band will read as more dramatic than it is until it has seen a wider one. That is the price of not having to tell it the units. - **131 glomeruli cannot give every state its own.** Collisions happen, as they do in a real fly with ~50 glomeruli, and are resolved downstream by the divergence onto Kenyon cells rather than avoided. It is not conscious, it is not an agent, it does not understand your house, and it is not a scientific instrument. It is a small animal's wiring diagram with your sensors plugged into it. --- ## Installation HouseFly is not in the default catalogue yet, so add it as a custom repository: `https://github.com/Vortitron/HouseFly`, category **Integration**. From Home Assistant 2026.11 that is under **Settings → Marketplace**, which replaces HACS and needs a GitHub sign-in for custom repositories; before 2026.11 it is **HACS → ⋮ → Custom repositories**. Install, restart, then **Settings → Devices & Services → Add Integration → HouseFly**. Every push runs HACS's own validation action and Home Assistant's hassfest, so whether this is installable is upstream's verdict rather than ours — currently 9/9 and passing. Actuation is **off by default**. A fresh install watches and walks; it touches nothing until you deliberately turn it on. ### Cards Both register themselves, so no resource setup is needed. ```yaml # Lets the fly out onto this dashboard. Draws nothing itself: it reports where # your cards are and paints the fly over the top of them. - type: custom:housefly-overlay show_debug: true # card outlines and a live brain readout # The connectome, with live activity in it. - type: custom:housefly-brain-card ``` ### Blueprint [`blueprints/automation/housefly/unusual_notify.yaml`](blueprints/automation/housefly/unusual_notify.yaml) turns the unusual alert into a notification. Import it with the button under [What you get](#what-you-get), or paste its URL into **Settings → Automations & Scenes → Blueprints → Import**. Pick the flies to listen to; choose whether to hear about alerts where only the hour is strange; add your own actions if you want it on your phone. `tools/validate.py` checks that every attribute it reads is one the sensor actually reports. ### Services | Service | Effect | |---|---| | `fly_house.loom` | Drives LPLC2 → escape. Also punishment, so it learns to dislike where it was. | | `fly_house.feed` | Drives the PAM dopaminergic neurons. Reward, and reduces hunger. | | `fly_house.reset_memory` | Every KC→MBON synapse back to its measured strength. | Clicking the fly on the dashboard calls `loom` with a strength set by how close you got. --- ## Safety The fly can only touch things you allowlisted, and only some of what you allowlist. See [`safety.py`](custom_components/fly_house/safety.py). - **Never, regardless of configuration:** `lock`, `alarm_control_panel`, `cover`, `climate`, `water_heater`, `humidifier`, `valve`, `vacuum`, `lawn_mower`, `script`, `automation`, `scene`, `input_boolean`. - **Refused by name** even in an allowed domain: anything containing `boiler`, `freezer`, `pump`, `oven`, `server`, `alarm`, `garage`, `charger`, `irrigation` and about twenty more. - **It has to land on something to touch it.** Actuation is triggered by the fly settling on a card, not by a timer — a few interactions an hour. - **Hourly budget**, per-entity cooldown, a deadband so a jittering brain cannot produce a call storm, and optional quiet hours. The deadband is measured against the thing *as it is* — a fly that lands on a lamp someone else has just switched off sees it off — and a lamp already as the fly wants it counts as contentment (`total_already_as_wanted`), not as a refusal (`total_blocked`). Until 2.19 the two were one number, measured against the fly's own last command, and on the sandboxes it ran to six hundred "refusals" a day per fly against two actions. - **Someone else's decision stands.** Whatever a person changed in the last hour, or an automation or another fly in the last ten minutes, the fly leaves alone (`total_left_to_others`). It tells them apart by the context Home Assistant stamps on every change; a wall switch counts as a person. - Bad choices are refused in the config flow, while you are still looking at it. Start it on a spare lamp. --- ## Try it without risking anything `testbed/` brings up a throwaway Home Assistant containing a fake ten-room house — 17 lights, 15 switches, 30 drifting sensors, 13 motion detectors, all template helpers with nothing behind them — plus six entities that exist purely so you can watch the safety layer refuse them. ```bash cd testbed && ./up.sh # http://localhost:8124 ``` The dashboard is generated from the same entity list as the house, so it cannot drift into a wall of "entity not found". Safe to hand to other people. Booted and verified against Home Assistant 2026.9.2: 92 entities register cleanly (24 lights, 23 switches, 30 drifting sensors, 15 binary sensors), and all six trap entities are refused by name — `boiler`, `freezer`, `network`, `pump`, `door`, `heater`. --- ## Verifying the claims ```bash python3 tools/validate.py # runs the shipped brain, checks every claim above python3 tools/build_connectome.py # rebuilds the data pack from the public sources ``` CI runs `validate.py` on every push, so these numbers are checked rather than asserted. A longer write-up lives at **[housefly.vome.io](https://housefly.vome.io)**. Twenty checks. Cell counts against the literature; the Mexican hat; PEN hemispheres shifting oppositely; the bump forming, holding still and integrating turns with the right sign; punishment depressing KC→MBON synapses; looming raising descending drive *and then stopping*; PFL3 not being saturated flat; and arousal peaking at dawn and dusk. It prints the numbers, including the marginal ones. Current output: ``` a bump forms peak 0.223, trough 0.000 it holds still with no self-motion 0 deg of drift turning rotates the bump, right direction correlation +0.99 escape is transient, not a permanent mood escape fell back to 0.0000 arousal 03:00 0.17 06:00 1.00 12:00 0.08 18:45 1.00 23:00 0.10 ``` --- ## Data and credit Both datasets are CC-BY 4.0 and are fetched at build time, not vendored. - **Connectivity, cell types, columnar labels** — Janelia hemibrain v1.2. Scheffer, Xu, Januszewski, Lu, Takemura, Hayworth et al. (2020), *A connectome and analysis of the adult Drosophila central brain*, eLife 9:e57443. - **Soma positions, transmitter predictions** — FlyWire FAFB. Schlegel, Yin, Bates, Dorkenwald, Eichler, Brooks et al. (2024), *Whole-brain annotation and multi-connectome cell typing of Drosophila*, Nature 634:139–152. Modelling approach after Shiu, Sterne, Spiller, Franconville, Sandoval, Zhou et al. (2024), *A leaky integrate-and-fire computational model based on the connectome of the entire adult Drosophila brain*, Nature 634:210–219. The flies did the hard part. --- ## Licence MIT. See [LICENSE](LICENSE).