Test a pipeline that reads a stream of records, such as a detector or an alert rule, against a fault that a seeded run plants and labels. The run is the same every time, so the test can say which fault the pipeline must find and where.
Before You Start
Live Parameters explains the scenario step and the label record it publishes.
Test a Model tests the model itself. This page tests a program that reads the model's records.
You need pytest: pip install pytest, and the JSON Lines sink, which needs the parquet extra: pip install "desflow[parquet]".
Steps
Plant a Fault and Label It
The shop takes an order every 2 seconds on average. A scenario step at 300 seconds triples the rate, and its record publishes a ground-truth label of the fault. A second step at 360 seconds puts the rate back, with no label. Two sinks write what the run produces. The data sink gets the events only. The labels sink gets the labels only, so the pipeline under test never sees them. OUT, or the folder of the function, is where the two sinks write:
egress:# The data under test: events only, with no label-type:Jsonlconfig:{default_path:"${OUT:-output}/data/events.jsonl"}when:events# The labels only, for the scorer-type:Jsonlconfig:{default_path:"${OUT:-output}/labels/labels.jsonl"}when:labels
scenario:# The fault: the order rate triples at 300 seconds, and the label says so-at:300path:Shop.arrival.orders.ratevalue:1.5record:{fault:order_surge}# The end of the fault, which needs no label-{at:360, path:Shop.arrival.orders.rate, value:0.5}
run_shop runs the blueprint, with OUT set to the folder:
defrun_shop(folder:Path)->None:"""Runs the shop of the blueprint, writing its data and labels under `folder`."""blueprint=Path(__file__).with_name("test_a_pipeline.yaml")SimulationContext.from_yaml(blueprint,variables={"OUT":str(folder)}).run()
defrun_shop(folder:Path)->None:"""Runs the shop, writing its data and labels under `folder`."""app=(SimulationContext(sim_id="Shop",factor=0,random_seed=42).add_resource("packers",current_cap=3,max_cap=5).add_service("packing",dist="constant",value=1.5).add_arrival("orders",dist="exponential",rate=0.5,spawn="pack_order")# The data under test: events only, with no label.add_egress(JsonlStorageEgress(default_path=f"{folder}/data/events.jsonl"),when=lambdarecord:record["stream_type"]=="event",)# The labels only, for the scorer.add_egress(JsonlStorageEgress(default_path=f"{folder}/labels/labels.jsonl"),when="labels",).add_scenario([# The fault: the order rate triples at 300 seconds, and the label says so{"at":300,"path":"Shop.arrival.orders.rate","value":1.5,"record":{"fault":"order_surge"},},# The end of the fault, which needs no label{"at":360,"path":"Shop.arrival.orders.rate","value":0.5},]))@app.task(name="pack_order",service_id="packing",resource_id="packers")defpack_order(task_id):return{"event_type":"order_packed"}app.run(until="10 min")
defrun_shop(folder:Path)->None:"""Runs the shop, writing its data and labels under `folder`."""env=DynamicRealtimeEnvironment(factor=0)env.registry.register_sim_parameter(PARAMS)env.setup_egress([# The data under test: events only, with no labelJsonlStorageEgress(default_path=f"{folder}/data/events.jsonl"),# The labels only, for the scorerJsonlStorageEgress(default_path=f"{folder}/labels/labels.jsonl"),],predicates=[lambdarecord:record["stream_type"]=="event",None],labels=[False,True],)packers=DynamicResource(env,"Shop","packers")env.process(run_scenario(env,STEPS,until=600))env.process(orders(env,packers,Sampler(seed=42)))try:env.run(until=600)finally:env.teardown()
Each sink writes one file for each batch, named events_<8 hex digits>.jsonl and labels_<8 hex digits>.jsonl. The label file holds one line, with sim_ts 300 and the label {"fault": "order_surge"}.
Write the Pipeline Under Test
The pipeline counts the finished orders in each window of 10 seconds and flags a window when its count is more than 2.5 times the median count. A surge shows as flagged windows:
defcount_orders(events:list[dict],window:int)->list[int]:"""Returns the number of finished orders in each window, from the first window."""packed:Counter=Counter()foreventinevents:if"event_type"inevent:packed[int(event["sim_ts"]//window)]+=1return[packed[number]fornumberinrange(max(packed)+1)]deffind_surges(events:list[dict],window:int=10,factor:float=2.5)->list[int]:"""Returns the start of each window with more than `factor` times the usual orders."""counts=count_orders(events,window)limit=factor*statistics.median(counts)starts=[]fornumber,countinenumerate(counts):ifcount>limit:starts.append(number*window)returnstarts
Score It Against the Labels
A fault is caught when a window is flagged within 60 seconds of its label. A flagged window with no fault before it is a false alarm:
defscore(found:list[int],labels:list[dict],within:int=60)->dict:"""Returns how many planted faults the flagged windows caught and missed, and how many flagged windows had no fault before them."""faults=[label["sim_ts"]forlabelinlabels]caught=0forfaultinfaults:ifany(fault<=start<fault+withinforstartinfound):caught+=1false_alarms=0forstartinfound:ifnotany(fault<=start<fault+withinforfaultinfaults):false_alarms+=1return{"caught":caught,"missed":len(faults)-caught,"false":false_alarms,}
For this run the pipeline flags the windows that start at 330, 340 and 350 seconds. A window is flagged only when it holds more than 2.5 times the usual number of finished orders, so the first flagged window starts at 330 seconds, 30 seconds after the label. The score is one fault caught, none missed and no false alarm.
Write the Tests
The tests run the shop once, and check the labels, the data and the pipeline. The last test shows that the check can fail: a pipeline that flags only a window with ten times the usual orders misses the fault:
Save the shop of your form as pipeline_shop.py, in a folder with pipeline_under_test.py and test_a_pipeline.py. For the YAML form, add test_a_pipeline.yaml to the folder. Then run:
pytest reports 4 passed. The three forms flag the same windows and give the same score.
A test of this kind states what a pipeline does on one seeded run. A new release of desflow may draw its random numbers differently, so after an upgrade, run the tests again and read any change before you keep it.
"""The pipeline under test and its scorer, for the Test a Pipeline page.The pipeline reads the events of a run and flags the windows with unusually many orders.The scorer compares what it flagged with the labels of the faults the run planted."""importglobimportjsonimportstatisticsfromcollectionsimportCounterdefread_lines(folder:str)->list[dict]:"""Every JSON line of every file under `folder`, in time order."""lines=[]forpathinglob.glob(f"{folder}/*.jsonl"):withopen(path)asfile:lines+=[json.loads(line)forlineinfile]returnsorted(lines,key=lambdaline:line["sim_ts"])defcount_orders(events:list[dict],window:int)->list[int]:"""Returns the number of finished orders in each window, from the first window."""packed:Counter=Counter()foreventinevents:if"event_type"inevent:packed[int(event["sim_ts"]//window)]+=1return[packed[number]fornumberinrange(max(packed)+1)]deffind_surges(events:list[dict],window:int=10,factor:float=2.5)->list[int]:"""Returns the start of each window with more than `factor` times the usual orders."""counts=count_orders(events,window)limit=factor*statistics.median(counts)starts=[]fornumber,countinenumerate(counts):ifcount>limit:starts.append(number*window)returnstartsdefscore(found:list[int],labels:list[dict],within:int=60)->dict:"""Returns how many planted faults the flagged windows caught and missed, and how many flagged windows had no fault before them."""faults=[label["sim_ts"]forlabelinlabels]caught=0forfaultinfaults:ifany(fault<=start<fault+withinforstartinfound):caught+=1false_alarms=0forstartinfound:ifnotany(fault<=start<fault+withinforfaultinfaults):false_alarms+=1return{"caught":caught,"missed":len(faults)-caught,"false":false_alarms,}
"""The Test a Pipeline page: tests of a pipeline against the labels of a planted fault.Save the shop of your form from the page as pipeline_shop.py in the folder of this file,with pipeline_under_test.py, and run: pytest test_a_pipeline.py"""importpytestfrompipeline_shopimportrun_shopfrompipeline_under_testimportfind_surges,read_lines,score@pytest.fixture(scope="module")defrun(tmp_path_factory):"""One run of the shop: its events and its labels."""folder=tmp_path_factory.mktemp("run")run_shop(folder)returnread_lines(f"{folder}/data"),read_lines(f"{folder}/labels")deftest_the_labels_name_the_planted_fault(run):_,labels=runassert[label["label"]forlabelinlabels]==[{"fault":"order_surge"}]assert[label["sim_ts"]forlabelinlabels]==[300.0]deftest_the_data_carries_no_label(run):events,_=runassert{event["stream_type"]foreventinevents}=={"event"}deftest_the_pipeline_catches_the_fault_without_a_false_alarm(run):events,labels=runassertscore(find_surges(events),labels)=={"caught":1,"missed":0,"false":0}deftest_a_pipeline_that_is_too_strict_misses_the_fault(run):events,labels=runassertscore(find_surges(events,factor=10),labels)["missed"]==1
# The Test a Pipeline guide: a shop with a planted fault. Orders surge at 300 seconds# and the label of the fault goes only to the labels sink. OUT is the folder the two# sinks write under. Run it with: desflow run test_a_pipeline.yamlsimulation:{sim_id:Shop, factor:0, random_seed:42}resources:packers:{current_cap:3, max_cap:5}services:packing:{dist:constant, value:1.5}arrivals:orders:{dist:exponential, rate:0.5, spawn:pack_order}tasks:pack_order:service:packingresource:packerspayload:{event_type:order_packed}egress:# The data under test: events only, with no label-type:Jsonlconfig:{default_path:"${OUT:-output}/data/events.jsonl"}when:events# The labels only, for the scorer-type:Jsonlconfig:{default_path:"${OUT:-output}/labels/labels.jsonl"}when:labelsscenario:# The fault: the order rate triples at 300 seconds, and the label says so-at:300path:Shop.arrival.orders.ratevalue:1.5record:{fault:order_surge}# The end of the fault, which needs no label-{at:360, path:Shop.arrival.orders.rate, value:0.5}run:{until:10 min}
"""The Test a Pipeline page, YAML form: runs test_a_pipeline.yaml, beside this file, withits sinks under `folder`. Save it as pipeline_shop.py for the tests."""frompathlibimportPathfromdesflowimportSimulationContextdefrun_shop(folder:Path)->None:"""Runs the shop of the blueprint, writing its data and labels under `folder`."""blueprint=Path(__file__).with_name("test_a_pipeline.yaml")SimulationContext.from_yaml(blueprint,variables={"OUT":str(folder)}).run()
"""The Test a Pipeline page, builder form: the shop with a planted fault. Save it aspipeline_shop.py for the tests."""frompathlibimportPathfromdesflowimportJsonlStorageEgress,SimulationContextdefrun_shop(folder:Path)->None:"""Runs the shop, writing its data and labels under `folder`."""app=(SimulationContext(sim_id="Shop",factor=0,random_seed=42).add_resource("packers",current_cap=3,max_cap=5).add_service("packing",dist="constant",value=1.5).add_arrival("orders",dist="exponential",rate=0.5,spawn="pack_order")# The data under test: events only, with no label.add_egress(JsonlStorageEgress(default_path=f"{folder}/data/events.jsonl"),when=lambdarecord:record["stream_type"]=="event",)# The labels only, for the scorer.add_egress(JsonlStorageEgress(default_path=f"{folder}/labels/labels.jsonl"),when="labels",).add_scenario([# The fault: the order rate triples at 300 seconds, and the label says so{"at":300,"path":"Shop.arrival.orders.rate","value":1.5,"record":{"fault":"order_surge"},},# The end of the fault, which needs no label{"at":360,"path":"Shop.arrival.orders.rate","value":0.5},]))@app.task(name="pack_order",service_id="packing",resource_id="packers")defpack_order(task_id):return{"event_type":"order_packed"}app.run(until="10 min")
"""The Test a Pipeline page, low-level form: the shop with a planted fault. Save it aspipeline_shop.py for the tests."""importitertoolsfrompathlibimportPathfromdesflowimport(CapacityConfig,DistributionConfig,DynamicRealtimeEnvironment,DynamicResource,JsonlStorageEgress,Sampler,SimParameter,run_scenario,)PARAMS=SimParameter(sim_id="Shop",arrival={"orders":DistributionConfig(dist="exponential",rate=0.5)},service={"packing":DistributionConfig(dist="constant",value=1.5)},resources={"packers":CapacityConfig(current_cap=3,max_cap=5)},)# The fault: the order rate triples at 300 seconds, and the label says soSTEPS=[{"at":300,"path":"Shop.arrival.orders.rate","value":1.5,"record":{"fault":"order_surge"},},# The end of the fault, which needs no label{"at":360,"path":"Shop.arrival.orders.rate","value":0.5},]defpack_order(env,packers,sampler,key,task_id):withpackers.request()asunit:yieldunityieldenv.timeout(sampler.sample(env.registry.get_config("Shop.service.packing")))env.publish_event(key,{"event_type":"order_packed"},"pack_order",task_id)deforders(env,packers,sampler):gap=env.registry.get_config("Shop.arrival.orders")fornumberinitertools.count(1):yieldenv.timeout(sampler.sample(gap))env.process(pack_order(env,packers,sampler,f"orders-{number}",number-1))defrun_shop(folder:Path)->None:"""Runs the shop, writing its data and labels under `folder`."""env=DynamicRealtimeEnvironment(factor=0)env.registry.register_sim_parameter(PARAMS)env.setup_egress([# The data under test: events only, with no labelJsonlStorageEgress(default_path=f"{folder}/data/events.jsonl"),# The labels only, for the scorerJsonlStorageEgress(default_path=f"{folder}/labels/labels.jsonl"),],predicates=[lambdarecord:record["stream_type"]=="event",None],labels=[False,True],)packers=DynamicResource(env,"Shop","packers")env.process(run_scenario(env,STEPS,until=600))env.process(orders(env,packers,Sampler(seed=42)))try:env.run(until=600)finally:env.teardown()