Adds the insights reader, a pure guardrail evaluator, and the sweep that
applies them to live campaigns.
Three things carry the weight here:
Units. Meta reports spend in major units ("12.34") while budgets and
guardrails are in minor units (1234). normalise_row converts, rounding
half up rather than using round(), which rounds halves to even and can
record an exact half-unit of spend as nothing.
Leads. Meta has no leads field; leads live in the actions array, under
several action types depending on whether the lead came from a Facebook
form or a pixel. Cost per lead is computed from spend and leads over the
same window rather than read from cost_per_action_type, so the two can
never disagree.
Order. The campaign is paused on Meta before the local record changes. A
campaign recorded as paused but still delivering is the outcome this
exists to prevent. One campaign's failure never aborts the sweep, so a
rate limit on the third does not leave the fourth unguarded.
Cost rules are skipped until the first lead or click: no leads yet is not
an infinite cost per lead, and pausing for that would kill every campaign
in its first hour.
Deliberately a deterministic loop rather than a maskanx_cron_jobs entry.
That scheduler runs prompts through an agent, and asking a language model
whether a budget has been exceeded would make an arithmetic guarantee
probabilistic. Follows reconcile.py's lifespan-task pattern instead, and
warns every cycle if Meta is unconfigured — a safety system that cannot
run should be loud.
require_approval_for_budget_increase is enforced at the API layer, where
the budget is actually edited: raising it while a campaign awaits approval
returns 409, since it would change what the approver is reviewing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
4.4 KiB
Python
179 lines
4.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Guardrail evaluation.
|
|
|
|
These rules are the last automatic defence against a campaign spending more
|
|
than intended, so the tests are mostly about the ways they could fail to
|
|
fire — or fire when they should not.
|
|
"""
|
|
import pytest
|
|
|
|
from adclaw.campaigns.guardrails import evaluate
|
|
|
|
|
|
def _rules(**overrides):
|
|
rules = {"stop_loss_enabled": True}
|
|
rules.update(overrides)
|
|
return rules
|
|
|
|
|
|
def test_no_rules_means_no_action():
|
|
assert evaluate({}, today_spend=999999, lifetime_spend=999999) is None
|
|
|
|
|
|
def test_daily_spend_at_the_limit_pauses():
|
|
"""'reaches' is inclusive: at the limit is at the limit."""
|
|
breach = evaluate(
|
|
_rules(auto_pause_if_spend_reaches=45000),
|
|
today_spend=45000,
|
|
lifetime_spend=45000,
|
|
)
|
|
|
|
assert breach is not None
|
|
assert breach.action == "pause"
|
|
assert breach.rule == "auto_pause_if_spend_reaches"
|
|
assert breach.observed == 45000
|
|
|
|
|
|
def test_daily_spend_below_the_limit_does_nothing():
|
|
assert (
|
|
evaluate(
|
|
_rules(auto_pause_if_spend_reaches=45000),
|
|
today_spend=44999,
|
|
lifetime_spend=44999,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_lifetime_cap_stops_rather_than_pauses():
|
|
breach = evaluate(
|
|
_rules(max_campaign_spend=500000),
|
|
today_spend=1000,
|
|
lifetime_spend=500000,
|
|
)
|
|
|
|
assert breach.action == "stop"
|
|
assert breach.rule == "max_campaign_spend"
|
|
|
|
|
|
def test_lifetime_cap_needs_stop_loss_enabled():
|
|
"""The switch exists so the permanent rule can be turned off alone."""
|
|
assert (
|
|
evaluate(
|
|
_rules(max_campaign_spend=500000, stop_loss_enabled=False),
|
|
today_spend=1000,
|
|
lifetime_spend=999999,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_disabling_stop_loss_leaves_the_pause_rules_working():
|
|
breach = evaluate(
|
|
_rules(
|
|
max_campaign_spend=500000,
|
|
auto_pause_if_spend_reaches=45000,
|
|
stop_loss_enabled=False,
|
|
),
|
|
today_spend=45000,
|
|
lifetime_spend=999999,
|
|
)
|
|
|
|
assert breach.action == "pause"
|
|
|
|
|
|
def test_the_most_severe_breach_wins():
|
|
"""Blowing both caps should stop the campaign, not merely pause it."""
|
|
breach = evaluate(
|
|
_rules(max_campaign_spend=500000, auto_pause_if_spend_reaches=1000),
|
|
today_spend=90000,
|
|
lifetime_spend=600000,
|
|
)
|
|
|
|
assert breach.action == "stop"
|
|
|
|
|
|
def test_cost_per_lead_over_the_limit_pauses():
|
|
breach = evaluate(
|
|
_rules(max_cost_per_lead=15000),
|
|
today_spend=60000,
|
|
lifetime_spend=60000,
|
|
cost_per_lead=20000,
|
|
)
|
|
|
|
assert breach.rule == "max_cost_per_lead"
|
|
assert breach.action == "pause"
|
|
|
|
|
|
def test_cost_rules_are_skipped_before_the_first_lead():
|
|
"""No leads yet is not an infinite cost per lead."""
|
|
assert (
|
|
evaluate(
|
|
_rules(max_cost_per_lead=100),
|
|
today_spend=60000,
|
|
lifetime_spend=60000,
|
|
cost_per_lead=None,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_cost_per_lead_exactly_at_the_limit_is_allowed():
|
|
"""'max' is a ceiling that may be reached, unlike 'reaches'."""
|
|
assert (
|
|
evaluate(
|
|
_rules(max_cost_per_lead=15000),
|
|
today_spend=15000,
|
|
lifetime_spend=15000,
|
|
cost_per_lead=15000,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_cost_per_click_over_the_limit_pauses():
|
|
breach = evaluate(
|
|
_rules(max_cost_per_click=2000),
|
|
today_spend=60000,
|
|
lifetime_spend=60000,
|
|
cost_per_click=2001,
|
|
)
|
|
|
|
assert breach.rule == "max_cost_per_click"
|
|
|
|
|
|
def test_a_limit_of_zero_is_honoured_not_read_as_unset():
|
|
"""Someone who caps spend at zero means it."""
|
|
breach = evaluate(
|
|
_rules(auto_pause_if_spend_reaches=0),
|
|
today_spend=0,
|
|
lifetime_spend=0,
|
|
)
|
|
|
|
assert breach is not None
|
|
assert breach.limit == 0
|
|
|
|
|
|
@pytest.mark.parametrize("value", [None, "not a number", True, False])
|
|
def test_an_unusable_limit_is_treated_as_absent(value):
|
|
assert (
|
|
evaluate(
|
|
_rules(auto_pause_if_spend_reaches=value),
|
|
today_spend=999999,
|
|
lifetime_spend=999999,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_the_reason_names_the_rule_and_both_numbers():
|
|
breach = evaluate(
|
|
_rules(auto_pause_if_spend_reaches=45000),
|
|
today_spend=46000,
|
|
lifetime_spend=46000,
|
|
)
|
|
|
|
assert "auto_pause_if_spend_reaches" in breach.reason
|
|
assert "46000" in breach.reason
|
|
assert "45000" in breach.reason
|