[{"data":1,"prerenderedAt":1113},["ShallowReactive",2],{"page-\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fmodeling-a-cache-with-invariants-and-bundles\u002F":3},{"id":4,"title":5,"body":6,"description":1079,"extension":1080,"meta":1081,"navigation":94,"path":1109,"seo":1110,"stem":1111,"__hash__":1112},"content\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fmodeling-a-cache-with-invariants-and-bundles\u002Findex.md","Modeling a Cache with Invariants and Bundles",{"type":7,"value":8,"toc":1068},"minimark",[9,22,29,34,56,60,438,593,597,608,624,628,656,675,769,773,776,831,839,843,846,863,866,888,954,958,998,1002,1011,1021,1027,1031,1059,1064],[10,11,12,13,17,18,21],"p",{},"Caches are small, stateful and easy to get subtly wrong. An LRU cache has to update recency on reads as well as writes, evict exactly the least recently used entry when full, handle overwriting an existing key without evicting anything, and keep its size bookkeeping consistent through deletes. Each of those is simple in isolation. Bugs live in sequences: a ",[14,15,16],"code",{},"get"," that forgets to refresh recency only matters if a ",[14,19,20],{},"put"," later triggers an eviction; a delete that leaves a stale entry in the recency list only matters when that key is re-inserted at capacity.",[10,23,24,25,28],{},"Example tests check the sequences someone thought to write. A Hypothesis ",[14,26,27],{},"RuleBasedStateMachine"," generates the sequences, runs them against the real cache and a deliberately simple model at the same time, and checks after every step that the two agree. When they diverge, Hypothesis shrinks the sequence to the shortest series of operations that reproduces the difference.",[30,31,33],"h2",{"id":32},"prerequisites","Prerequisites",[35,36,37,48],"ul",{},[38,39,40,43,44,47],"li",{},[14,41,42],{},"hypothesis >= 6.100",", ",[14,45,46],{},"pytest >= 8.0",".",[38,49,50,51,47],{},"The state machine basics from ",[52,53,55],"a",{"href":54},"\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002F","Stateful and model-based testing",[30,57,59],{"id":58},"solution","Solution",[61,62,67],"pre",{"className":63,"code":64,"language":65,"meta":66,"style":66},"language-python shiki shiki-themes github-light github-dark","# test_lru_machine.py\nfrom hypothesis import strategies as st\nfrom hypothesis.stateful import Bundle, RuleBasedStateMachine, consumes, invariant, rule\n\nfrom myapp.cache import LRUCache\n\nCAPACITY = 3\nkeys_st = st.text(alphabet=\"abcdef\", min_size=1, max_size=2)\nvalues_st = st.integers()\n\nclass LRUMachine(RuleBasedStateMachine):\n    keys = Bundle(\"keys\")\n\n    def __init__(self):\n        super().__init__()\n        self.cache = LRUCache(capacity=CAPACITY)\n        self.model: dict[str, int] = {}\n        self.order: list[str] = []            # least recent first\n\n    def _touch(self, k):\n        if k in self.order:\n            self.order.remove(k)\n        self.order.append(k)\n\n    @rule(target=keys, k=keys_st, v=values_st)\n    def put(self, k, v):\n        self.cache.put(k, v)\n        if k not in self.model and len(self.model) == CAPACITY:\n            evicted = self.order.pop(0)\n            del self.model[evicted]\n        self.model[k] = v\n        self._touch(k)\n        return k\n\n    @rule(k=keys)\n    def get_known(self, k):\n        expected = self.model.get(k)\n        assert self.cache.get(k) == expected\n        if expected is not None:\n            self._touch(k)\n\n    @rule(k=keys_st)\n    def get_arbitrary(self, k):\n        expected = self.model.get(k)\n        assert self.cache.get(k) == expected\n        if expected is not None:\n            self._touch(k)\n\n    @rule(k=consumes(keys))\n    def delete(self, k):\n        self.cache.delete(k)\n        self.model.pop(k, None)\n        if k in self.order:\n            self.order.remove(k)\n\n    @invariant()\n    def size_within_capacity(self):\n        assert len(self.cache) \u003C= CAPACITY\n\n    @invariant()\n    def contents_match_model(self):\n        assert dict(self.cache.items()) == self.model\n\nTestLRU = LRUMachine.TestCase\n","python","",[14,68,69,77,83,89,96,102,107,113,119,125,130,136,142,147,153,159,165,171,177,182,188,194,200,206,211,217,223,229,235,241,247,253,259,265,270,276,282,288,294,300,306,311,317,323,328,333,338,343,348,354,360,366,372,377,382,387,393,399,405,410,415,421,427,432],{"__ignoreMap":66},[70,71,74],"span",{"class":72,"line":73},"line",1,[70,75,76],{},"# test_lru_machine.py\n",[70,78,80],{"class":72,"line":79},2,[70,81,82],{},"from hypothesis import strategies as st\n",[70,84,86],{"class":72,"line":85},3,[70,87,88],{},"from hypothesis.stateful import Bundle, RuleBasedStateMachine, consumes, invariant, rule\n",[70,90,92],{"class":72,"line":91},4,[70,93,95],{"emptyLinePlaceholder":94},true,"\n",[70,97,99],{"class":72,"line":98},5,[70,100,101],{},"from myapp.cache import LRUCache\n",[70,103,105],{"class":72,"line":104},6,[70,106,95],{"emptyLinePlaceholder":94},[70,108,110],{"class":72,"line":109},7,[70,111,112],{},"CAPACITY = 3\n",[70,114,116],{"class":72,"line":115},8,[70,117,118],{},"keys_st = st.text(alphabet=\"abcdef\", min_size=1, max_size=2)\n",[70,120,122],{"class":72,"line":121},9,[70,123,124],{},"values_st = st.integers()\n",[70,126,128],{"class":72,"line":127},10,[70,129,95],{"emptyLinePlaceholder":94},[70,131,133],{"class":72,"line":132},11,[70,134,135],{},"class LRUMachine(RuleBasedStateMachine):\n",[70,137,139],{"class":72,"line":138},12,[70,140,141],{},"    keys = Bundle(\"keys\")\n",[70,143,145],{"class":72,"line":144},13,[70,146,95],{"emptyLinePlaceholder":94},[70,148,150],{"class":72,"line":149},14,[70,151,152],{},"    def __init__(self):\n",[70,154,156],{"class":72,"line":155},15,[70,157,158],{},"        super().__init__()\n",[70,160,162],{"class":72,"line":161},16,[70,163,164],{},"        self.cache = LRUCache(capacity=CAPACITY)\n",[70,166,168],{"class":72,"line":167},17,[70,169,170],{},"        self.model: dict[str, int] = {}\n",[70,172,174],{"class":72,"line":173},18,[70,175,176],{},"        self.order: list[str] = []            # least recent first\n",[70,178,180],{"class":72,"line":179},19,[70,181,95],{"emptyLinePlaceholder":94},[70,183,185],{"class":72,"line":184},20,[70,186,187],{},"    def _touch(self, k):\n",[70,189,191],{"class":72,"line":190},21,[70,192,193],{},"        if k in self.order:\n",[70,195,197],{"class":72,"line":196},22,[70,198,199],{},"            self.order.remove(k)\n",[70,201,203],{"class":72,"line":202},23,[70,204,205],{},"        self.order.append(k)\n",[70,207,209],{"class":72,"line":208},24,[70,210,95],{"emptyLinePlaceholder":94},[70,212,214],{"class":72,"line":213},25,[70,215,216],{},"    @rule(target=keys, k=keys_st, v=values_st)\n",[70,218,220],{"class":72,"line":219},26,[70,221,222],{},"    def put(self, k, v):\n",[70,224,226],{"class":72,"line":225},27,[70,227,228],{},"        self.cache.put(k, v)\n",[70,230,232],{"class":72,"line":231},28,[70,233,234],{},"        if k not in self.model and len(self.model) == CAPACITY:\n",[70,236,238],{"class":72,"line":237},29,[70,239,240],{},"            evicted = self.order.pop(0)\n",[70,242,244],{"class":72,"line":243},30,[70,245,246],{},"            del self.model[evicted]\n",[70,248,250],{"class":72,"line":249},31,[70,251,252],{},"        self.model[k] = v\n",[70,254,256],{"class":72,"line":255},32,[70,257,258],{},"        self._touch(k)\n",[70,260,262],{"class":72,"line":261},33,[70,263,264],{},"        return k\n",[70,266,268],{"class":72,"line":267},34,[70,269,95],{"emptyLinePlaceholder":94},[70,271,273],{"class":72,"line":272},35,[70,274,275],{},"    @rule(k=keys)\n",[70,277,279],{"class":72,"line":278},36,[70,280,281],{},"    def get_known(self, k):\n",[70,283,285],{"class":72,"line":284},37,[70,286,287],{},"        expected = self.model.get(k)\n",[70,289,291],{"class":72,"line":290},38,[70,292,293],{},"        assert self.cache.get(k) == expected\n",[70,295,297],{"class":72,"line":296},39,[70,298,299],{},"        if expected is not None:\n",[70,301,303],{"class":72,"line":302},40,[70,304,305],{},"            self._touch(k)\n",[70,307,309],{"class":72,"line":308},41,[70,310,95],{"emptyLinePlaceholder":94},[70,312,314],{"class":72,"line":313},42,[70,315,316],{},"    @rule(k=keys_st)\n",[70,318,320],{"class":72,"line":319},43,[70,321,322],{},"    def get_arbitrary(self, k):\n",[70,324,326],{"class":72,"line":325},44,[70,327,287],{},[70,329,331],{"class":72,"line":330},45,[70,332,293],{},[70,334,336],{"class":72,"line":335},46,[70,337,299],{},[70,339,341],{"class":72,"line":340},47,[70,342,305],{},[70,344,346],{"class":72,"line":345},48,[70,347,95],{"emptyLinePlaceholder":94},[70,349,351],{"class":72,"line":350},49,[70,352,353],{},"    @rule(k=consumes(keys))\n",[70,355,357],{"class":72,"line":356},50,[70,358,359],{},"    def delete(self, k):\n",[70,361,363],{"class":72,"line":362},51,[70,364,365],{},"        self.cache.delete(k)\n",[70,367,369],{"class":72,"line":368},52,[70,370,371],{},"        self.model.pop(k, None)\n",[70,373,375],{"class":72,"line":374},53,[70,376,193],{},[70,378,380],{"class":72,"line":379},54,[70,381,199],{},[70,383,385],{"class":72,"line":384},55,[70,386,95],{"emptyLinePlaceholder":94},[70,388,390],{"class":72,"line":389},56,[70,391,392],{},"    @invariant()\n",[70,394,396],{"class":72,"line":395},57,[70,397,398],{},"    def size_within_capacity(self):\n",[70,400,402],{"class":72,"line":401},58,[70,403,404],{},"        assert len(self.cache) \u003C= CAPACITY\n",[70,406,408],{"class":72,"line":407},59,[70,409,95],{"emptyLinePlaceholder":94},[70,411,413],{"class":72,"line":412},60,[70,414,392],{},[70,416,418],{"class":72,"line":417},61,[70,419,420],{},"    def contents_match_model(self):\n",[70,422,424],{"class":72,"line":423},62,[70,425,426],{},"        assert dict(self.cache.items()) == self.model\n",[70,428,430],{"class":72,"line":429},63,[70,431,95],{"emptyLinePlaceholder":94},[70,433,435],{"class":72,"line":434},64,[70,436,437],{},"TestLRU = LRUMachine.TestCase\n",[439,440,443,589],"figure",{"className":441},[442],"diagram",[444,445,452,453,452,457,452,461,452,479,452,487,452,497,452,504,452,510,452,515,452,524,452,529,452,533,452,537,452,541,452,545,452,553,452,556,452,564,452,569,452,573,452,577,452,582,452,585],"svg",{"viewBox":446,"role":447,"ariaLabelledBy":448,"xmlns":451},"0 0 800 256","img",[449,450],"lc-t","lc-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[454,455,456],"title",{"id":449},"Driving the cache and the model in lockstep",[458,459,460],"desc",{"id":450},"Hypothesis picks a rule such as put, get or delete with generated arguments. Each rule applies the same operation to the real LRU cache and to the model made of a dict and a recency list. After every step, invariants check the cache size is within capacity and its contents equal the model.",[462,463,464,465,452],"defs",{},"\n    ",[466,467,474],"marker",{"id":468,"viewBox":469,"refX":470,"refY":471,"markerWidth":472,"markerHeight":472,"orient":473},"lc-a","0 0 10 10","9","5","7","auto-start-reverse",[475,476],"path",{"d":477,"fill":478},"M0 0 L10 5 L0 10 z","#81b29a",[480,481],"rect",{"x":482,"y":482,"width":483,"height":484,"rx":485,"fill":486},"0","800","256","14","#fffdf8",[488,489,496],"text",{"x":490,"y":491,"textAnchor":492,"fontSize":493,"fontWeight":494,"fill":495},"400","28","middle","15.5","700","#3d405b","Same operation, two implementations, checked every step",[480,498],{"x":499,"y":500,"width":501,"height":502,"rx":503,"fill":495},"30","94","170","64","10",[488,505,509],{"x":506,"y":507,"textAnchor":492,"fontSize":508,"fontWeight":494,"fill":486},"115","120","12","rule chosen",[488,511,514],{"x":506,"y":512,"textAnchor":492,"fontSize":513,"fill":486},"140","10.5","put · get · delete",[480,516],{"x":517,"y":518,"width":519,"height":520,"rx":503,"fill":521,"stroke":522,"strokeWidth":523},"300","52","210","60","#fbe9e3","#e07a5f","2",[488,525,528],{"x":526,"y":527,"textAnchor":492,"fontSize":508,"fontWeight":494,"fill":495},"405","78","LRUCache",[488,530,532],{"x":526,"y":531,"textAnchor":492,"fontSize":513,"fill":495},"98","code under test",[480,534],{"x":517,"y":535,"width":519,"height":520,"rx":503,"fill":536,"stroke":478,"strokeWidth":523},"142","#e6f0ea",[488,538,540],{"x":526,"y":539,"textAnchor":492,"fontSize":508,"fontWeight":494,"fill":495},"168","model",[488,542,544],{"x":526,"y":543,"textAnchor":492,"fontSize":513,"fill":495},"188","dict + recency list",[72,546],{"x1":547,"y1":548,"x2":549,"y2":550,"stroke":478,"strokeWidth":551,"markerEnd":552},"204","116","296","86","1.6","url(#lc-a)",[72,554],{"x1":547,"y1":555,"x2":549,"y2":539,"stroke":478,"strokeWidth":551,"markerEnd":552},"136",[480,557],{"x":558,"y":559,"width":560,"height":559,"rx":561,"fill":562,"stroke":563,"strokeWidth":523},"580","84","194","11","#f7f0da","#f2cc8f",[488,565,568],{"x":566,"y":567,"textAnchor":492,"fontSize":508,"fontWeight":494,"fill":495},"677","110","invariants",[488,570,572],{"x":566,"y":571,"textAnchor":492,"fontSize":513,"fill":495},"132","len ≤ capacity",[488,574,576],{"x":566,"y":575,"textAnchor":492,"fontSize":513,"fill":495},"152","contents == model",[72,578],{"x1":579,"y1":559,"x2":580,"y2":581,"stroke":478,"strokeWidth":551,"markerEnd":552},"514","576","112",[72,583],{"x1":579,"y1":584,"x2":580,"y2":535,"stroke":478,"strokeWidth":551,"markerEnd":552},"172",[488,586,588],{"x":490,"y":587,"textAnchor":492,"fontSize":561,"fill":495},"236","Divergence anywhere fails the run and shrinks the sequence.",[590,591,592],"figcaption",{},"The model is a few lines of obviously correct Python; the cache is whatever the optimised implementation does.",[30,594,596],{"id":595},"why-this-works","Why this works",[10,598,599,600,603,604,607],{},"The model is small enough to be obviously right: a dict for contents and a list for recency, with eviction taking the head of the list. It would be far too slow for production, which is exactly why nobody would have \"optimised\" it into bugs. The real cache — a dict plus a doubly linked list, or an ",[14,601,602],{},"OrderedDict"," with ",[14,605,606],{},"move_to_end"," — is compared against it after every operation, so any divergence is caught at the step that caused it rather than many steps later.",[10,609,610,611,614,615,618,619,623],{},"The two invariants divide the checking. ",[14,612,613],{},"size_within_capacity"," catches bookkeeping bugs even when contents happen to look right. ",[14,616,617],{},"contents_match_model"," catches eviction of the wrong key, stale values after overwrite, and entries that survive a delete. Because invariants run after ",[620,621,622],"em",{},"every"," rule, a bug introduced by any operation is detected immediately, and the shrunk counterexample ends at the step that broke the state.",[30,625,627],{"id":626},"bundles-operating-on-keys-that-exist","Bundles: operating on keys that exist",[10,629,630,631,634,635,637,638,641,642,645,646,648,649,651,652,655],{},"Without the ",[14,632,633],{},"keys"," Bundle, ",[14,636,16],{}," and ",[14,639,640],{},"delete"," would draw arbitrary keys from ",[14,643,644],{},"keys_st",". With a small alphabet that still works sometimes, but most gets would miss and most deletes would be no-ops, so the interesting paths — reading an existing entry, which refreshes recency — would be exercised rarely. The Bundle fixes the distribution: ",[14,647,20],{}," returns the key and targets it into ",[14,650,633],{},", and ",[14,653,654],{},"get_known"," draws only from keys that were inserted at some point.",[10,657,658,661,662,664,665,668,669,671,672,674],{},[14,659,660],{},"consumes(keys)"," on ",[14,663,640],{}," removes the drawn key from the Bundle, so later steps do not keep drawing a key the test knows is gone. Keeping ",[14,666,667],{},"get_arbitrary"," alongside ",[14,670,654],{}," preserves coverage of misses, including keys that were evicted — which are still in the Bundle, because eviction happens inside the cache, not through a rule. That mix is deliberate: ",[14,673,654],{}," on an evicted key is one of the most valuable checks the machine makes, since it asks \"does the cache agree this key is gone?\".",[439,676,678,766],{"className":677},[442],[444,679,452,684,452,687,452,690,452,697,452,699,452,702,452,707,452,711,452,714,452,718,452,722,452,727,452,733,452,738,452,743,452,748,452,750,452,754,452,760,452,763],{"viewBox":680,"role":447,"ariaLabelledBy":681,"xmlns":451},"0 0 800 236",[682,683],"lcb-t","lcb-d",[454,685,686],{"id":682},"How the keys Bundle feeds later rules",[458,688,689],{"id":683},"The put rule adds each inserted key to the keys Bundle. The get_known rule draws from the Bundle, including keys the cache has since evicted. The delete rule consumes a key from the Bundle so it is not drawn again. get_arbitrary draws from the full key strategy to keep testing misses.",[462,691,464,692,452],{},[466,693,695],{"id":694,"viewBox":469,"refX":470,"refY":471,"markerWidth":472,"markerHeight":472,"orient":473},"lcb-a",[475,696],{"d":477,"fill":478},[480,698],{"x":482,"y":482,"width":483,"height":587,"rx":485,"fill":486},[488,700,701],{"x":490,"y":491,"textAnchor":492,"fontSize":493,"fontWeight":494,"fill":495},"Keys flow from put to later rules",[480,703],{"x":499,"y":704,"width":705,"height":706,"rx":503,"fill":562,"stroke":563,"strokeWidth":523},"92","150","50",[488,708,20],{"x":709,"y":710,"textAnchor":492,"fontSize":508,"fontWeight":494,"fill":495},"105","122",[480,712],{"x":517,"y":527,"width":713,"height":527,"rx":508,"fill":536,"stroke":478,"strokeWidth":523},"200",[488,715,717],{"x":490,"y":716,"textAnchor":492,"fontSize":508,"fontWeight":494,"fill":495},"106","Bundle \"keys\"",[488,719,721],{"x":490,"y":720,"textAnchor":492,"fontSize":513,"fill":495},"128","\"a\" · \"cf\" · \"b\" · …",[488,723,726],{"x":490,"y":724,"textAnchor":492,"fontSize":503,"fill":725},"146","#2a5f49","evicted keys stay here",[72,728],{"x1":729,"y1":730,"x2":549,"y2":730,"stroke":478,"strokeWidth":731,"markerEnd":732},"184","117","1.8","url(#lcb-a)",[488,734,737],{"x":735,"y":736,"textAnchor":492,"fontSize":503,"fill":495},"240","108","target=",[480,739],{"x":740,"y":706,"width":705,"height":741,"rx":503,"fill":486,"stroke":495,"strokeWidth":742},"620","44","1.5",[488,744,654],{"x":745,"y":746,"textAnchor":492,"fontSize":747,"fill":495},"695","77","11.5",[480,749],{"x":740,"y":512,"width":705,"height":741,"rx":503,"fill":521,"stroke":522,"strokeWidth":731},[488,751,753],{"x":745,"y":752,"textAnchor":492,"fontSize":747,"fill":495},"167","delete (consumes)",[72,755],{"x1":756,"y1":757,"x2":758,"y2":759,"stroke":478,"strokeWidth":551,"markerEnd":732},"504","104","616","74",[72,761],{"x1":756,"y1":571,"x2":758,"y2":762,"stroke":522,"strokeWidth":551,"markerEnd":732},"160",[488,764,765],{"x":490,"y":519,"textAnchor":492,"fontSize":561,"fill":495},"get_arbitrary still draws from the full key strategy to cover misses",[590,767,768],{},"Bundles shift the distribution towards operations on real entries without giving up coverage of absent ones.",[30,770,772],{"id":771},"bugs-this-machine-finds","Bugs this machine finds",[10,774,775],{},"Three classic LRU bugs, and the shrunk sequences Hypothesis reports for them:",[35,777,778,796,814],{},[38,779,780,784,785,788,789,792,793,795],{},[781,782,783],"strong",{},"Reads do not refresh recency."," ",[14,786,787],{},"put(a) put(b) put(c) get_known(a) put(d) get_known(a)"," — the model evicted ",[14,790,791],{},"b",", the cache evicted ",[14,794,52],{},". Four puts and two gets, and the cause is visible at a glance.",[38,797,798,784,801,804,805,807,808,810,811,813],{},[781,799,800],{},"Overwrite evicts.",[14,802,803],{},"put(a) put(b) put(c) put(a)"," at capacity three — the cache evicted ",[14,806,791],{}," to make room for a key it already held. ",[14,809,613],{}," passes; ",[14,812,617],{}," fails.",[38,815,816,784,819,822,823,825,826,828,829,813],{},[781,817,818],{},"Delete leaves a ghost in the recency list.",[14,820,821],{},"put(a) delete(a) put(b) put(c) put(d) put(e)"," — the cache's linked list still held ",[14,824,52],{},", eviction removed the ghost instead of ",[14,827,791],{},", and size went to four. ",[14,830,613],{},[10,832,833,834,838],{},"Each shrinks to a sequence under ten steps, even when the first failing run contained fifty. That is the practical difference between this approach and a hand-written sequence test: the machine found the sequence, and shrinking made it readable. See ",[52,835,837],{"href":836},"\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fshrinking-long-rule-sequences-into-readable-repros\u002F","shrinking long rule sequences"," for how to help shrinking when it does not get this far on its own.",[30,840,842],{"id":841},"extending-the-machine-to-a-ttl-cache","Extending the machine to a TTL cache",[10,844,845],{},"Many production caches evict by age as well as by size, and time is where cache bugs become hardest to reproduce by hand. The state machine extends cleanly, provided time is something the test controls rather than something that passes.",[10,847,848,849,852,853,856,857,859,860,862],{},"Inject a clock into the cache — a callable returning the current time — and give the machine its own counter. Add one rule, ",[14,850,851],{},"advance(seconds)",", that moves the counter forward by a generated amount, from zero up to somewhat more than the TTL. The model records the insertion time for each key and treats an entry as absent once ",[14,854,855],{},"now - inserted >= ttl",". Everything else stays the same: ",[14,858,654],{}," asks both implementations for the key, and ",[14,861,617],{}," compares the live entries.",[10,864,865],{},"The interesting sequences now mix size and age. A key inserted just before capacity is reached and read just before its TTL expires should survive one eviction and then disappear on its own. An overwrite should reset the timer — or should it? That question is exactly what the machine forces into the open: the model has to choose, and once the choice is written down in the model, the real cache must match it. Teams often discover that two parts of their codebase assumed different answers.",[10,867,868,869,637,872,875,876,879,880,883,884,887],{},"Two practical details matter. First, keep the time values integers or exact fractions, because comparing float timestamps at the boundary makes the model and the cache disagree on rounding rather than on behaviour. Second, include ",[14,870,871],{},"advance(0)",[14,873,874],{},"advance(ttl)"," as explicit possibilities with ",[14,877,878],{},"st.sampled_from"," mixed into the duration strategy, because the boundary — an entry read at exactly its expiry time — is where off-by-one errors in ",[14,881,882],{},">"," versus ",[14,885,886],{},">="," hide. A random duration strategy alone would land on the exact boundary only occasionally; sampling it explicitly means every run of the machine exercises it several times, and a comparison bug surfaces on the first run after it is introduced rather than weeks later.",[439,889,891,946],{"className":890},[442],[444,892,452,897,452,900,452,903,452,906,452,909,452,913,452,917,452,920,452,923,452,926,452,930,452,935,452,937,452,942],{"viewBox":893,"role":447,"ariaLabelledBy":894,"xmlns":451},"0 0 800 226",[895,896],"lct-t","lct-d",[454,898,899],{"id":895},"Adding a controllable clock",[458,901,902],{"id":896},"A timeline shows a key inserted at time zero with a time-to-live of ten. An advance rule moves the test clock forward. A read at time nine returns the value, a read at exactly ten must return nothing, and the boundary is generated deliberately because off-by-one comparisons hide there.",[480,904],{"x":482,"y":482,"width":483,"height":905,"rx":485,"fill":486},"226",[488,907,908],{"x":490,"y":491,"textAnchor":492,"fontSize":493,"fontWeight":494,"fill":495},"Expiry under test-controlled time",[72,910],{"x1":911,"y1":507,"x2":912,"y2":507,"stroke":495,"strokeWidth":731},"70","740",[914,915],"circle",{"cx":916,"cy":507,"r":472,"fill":536,"stroke":478,"strokeWidth":523},"90",[488,918,919],{"x":916,"y":531,"textAnchor":492,"fontSize":561,"fill":725},"put(a) t=0",[914,921],{"cx":922,"cy":507,"r":472,"fill":536,"stroke":478,"strokeWidth":523},"470",[488,924,925],{"x":922,"y":531,"textAnchor":492,"fontSize":561,"fill":725},"get(a) t=9 → value",[72,927],{"x1":928,"y1":520,"x2":928,"y2":501,"stroke":563,"strokeWidth":929},"520","2.4",[488,931,934],{"x":928,"y":932,"textAnchor":492,"fontSize":561,"fontWeight":494,"fill":933},"54","#8a5a00","ttl = 10",[914,936],{"cx":928,"cy":507,"r":472,"fill":521,"stroke":522,"strokeWidth":523},[488,938,941],{"x":928,"y":939,"textAnchor":492,"fontSize":561,"fill":940},"192","#8f3d22","get(a) t=10 → None",[488,943,945],{"x":944,"y":705,"textAnchor":492,"fontSize":513,"fill":495},"660","advance(n) moves the clock",[590,947,948,949,951,952,47],{},"Generating the exact boundary is what catches a ",[14,950,882],{}," that should have been ",[14,953,886],{},[30,955,957],{"id":956},"edge-cases-and-failure-modes","Edge cases and failure modes",[35,959,960,966,976,982,992],{},[38,961,962,965],{},[781,963,964],{},"Model with the same bug."," If the model evicts on overwrite too, the machine agrees with a buggy cache. Keep the model naive and review it against the specification, not the implementation.",[38,967,968,971,972,975],{},[781,969,970],{},"Key space too large."," With ",[14,973,974],{},"st.text()"," unbounded, arbitrary keys never collide and overwrite paths are never exercised. A small alphabet makes collisions common.",[38,977,978,981],{},[781,979,980],{},"Capacity too large."," At capacity 100, eviction needs 101 distinct puts and rarely happens within the default step count. Test with small capacities; the logic is the same.",[38,983,984,987,988,991],{},[781,985,986],{},"TTL caches."," Time-based expiry needs a controllable clock injected into both cache and model, with an ",[14,989,990],{},"advance_time"," rule.",[38,993,994,997],{},[781,995,996],{},"Thread-safe caches."," A state machine tests sequential semantics. Concurrency needs separate tools; the machine still guards the single-threaded logic.",[30,999,1001],{"id":1000},"frequently-asked-questions","Frequently Asked Questions",[10,1003,1004,1007,1008,1010],{},[781,1005,1006],{},"What is a Bundle in a Hypothesis state machine?","\nA Bundle is a named collection of values produced by earlier rules. Rules can add to it with ",[14,1009,737],{}," and draw from it as an argument, which lets later steps operate on keys or objects that earlier steps actually created instead of on random values that almost never exist.",[10,1012,1013,1016,1017,1020],{},[781,1014,1015],{},"What is the difference between an invariant and a rule assertion?","\nA rule assertion checks the result of one operation. An ",[14,1018,1019],{},"@invariant"," runs after every step and checks a property of the whole state, such as the cache never exceeding capacity, so it catches corruption introduced by any operation.",[10,1022,1023,1026],{},[781,1024,1025],{},"How simple should the model be?","\nAs simple as possible while still predicting observable behaviour. For an LRU cache, a dict plus a list recording recency order is enough. If the model becomes as complex as the implementation, bugs can be copied into both.",[30,1028,1030],{"id":1029},"related","Related",[35,1032,1033,1039,1046,1052],{},[38,1034,1035,1038],{},[52,1036,1037],{"href":54},"Stateful and Model-Based Testing"," — RuleBasedStateMachine fundamentals.",[38,1040,1041,1045],{},[52,1042,1044],{"href":1043},"\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fdebugging-rulebasedstatemachine-failures\u002F","Debugging RuleBasedStateMachine Failures"," — reading step output.",[38,1047,1048,1051],{},[52,1049,1050],{"href":836},"Shrinking Long Rule Sequences into Readable Repros"," — when shrinking stalls.",[38,1053,1054,1058],{},[52,1055,1057],{"href":1056},"\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fmodeling-rest-apis-as-state-machines\u002F","Modeling REST APIs as State Machines"," — the same technique over HTTP.",[10,1060,1061,1062],{},"← Back to ",[52,1063,1037],{"href":54},[1065,1066,1067],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":66,"searchDepth":79,"depth":79,"links":1069},[1070,1071,1072,1073,1074,1075,1076,1077,1078],{"id":32,"depth":79,"text":33},{"id":58,"depth":79,"text":59},{"id":595,"depth":79,"text":596},{"id":626,"depth":79,"text":627},{"id":771,"depth":79,"text":772},{"id":841,"depth":79,"text":842},{"id":956,"depth":79,"text":957},{"id":1000,"depth":79,"text":1001},{"id":1029,"depth":79,"text":1030},"Test an LRU cache with a Hypothesis RuleBasedStateMachine: a dict-and-list model, Bundles for keys already inserted, invariants for size and recency, and eviction bugs it finds.","md",{"slug":1082,"type":1083,"breadcrumb":1084,"datePublished":1085,"dateModified":1085,"faq":1086,"howto":1093},"modeling-a-cache-with-invariants-and-bundles","article","Cache state machine","2026-09-18",[1087,1089,1091],{"q":1006,"a":1088},"A Bundle is a named collection of values produced by earlier rules. Rules can add to it with target= and draw from it as an argument, which lets later steps operate on keys or objects that earlier steps actually created instead of on random values that almost never exist.",{"q":1015,"a":1090},"A rule assertion checks the result of one operation. An @invariant runs after every step and checks a property of the whole state, such as the cache never exceeding capacity, so it catches corruption introduced by any operation.",{"q":1025,"a":1092},"As simple as possible while still predicting observable behaviour. For an LRU cache, a dict plus a list recording recency order is enough. If the model becomes as complex as the implementation, bugs can be copied into both.",{"name":1094,"description":1095,"steps":1096},"How to model a cache with a Hypothesis state machine","Write a simple model, drive the real cache and the model with the same rules, and check invariants after every step.",[1097,1100,1103,1106],{"name":1098,"text":1099},"Write the model","Represent the cache as a dict of values and a list of keys in recency order.",{"name":1101,"text":1102},"Define rules","Add rules for put, get of known keys, get of missing keys and delete, applied to both model and cache.",{"name":1104,"text":1105},"Use Bundles for known keys","Target inserted keys into a Bundle so later gets and deletes hit keys that exist.",{"name":1107,"text":1108},"Add invariants","Check size never exceeds capacity and that contents match the model after every step.","\u002Fproperty-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fmodeling-a-cache-with-invariants-and-bundles",{"title":5,"description":1079},"property-based-fuzz-testing-strategies\u002Fstateful-and-model-based-testing\u002Fmodeling-a-cache-with-invariants-and-bundles\u002Findex","6vd4xNg3MeE8Kafe9rCzkuwmcFu8cJbffYGFf0woDFw",1789718768442]