londec — Decision Maker
Evaluate tree-structured, JSON-serializable conditions against an ordered history of typed events. Each condition resolves to a Decision(satisfied, when) — satisfied an explicit bool, when an optional timestamp — enabling eligibility checks, scheduling triggers, and automation rules that are stored as data, not code.
londecwas created by Longenesis to solve complex eligibility rules in patient journeys — deciding, from a participant's accumulating history of activities and submissions, whether and when they qualify for the next step. The library itself is domain-agnostic and works with any ordered event history, not just healthcare data.
flowchart TD
subgraph events["Events (list[dict], oldest → newest)"]
E1["event_type: signup · Jan 5"]
E2["event_type: purchase · Jan 10"]
E3["event_type: purchase · Jan 18"]
end
EH["event_happened<br/>event_type: purchase<br/>→ Decision(True, Jan 18)"]
DL["delay · days: 14<br/>event_type: purchase<br/>→ Decision(True, Feb 1)"]
AND["AND<br/>max(Jan 18, Feb 1)"]
R(["Decision(True, Feb 1)"])
events -.->|evaluate| EH
events -.->|evaluate| DL
EH --> AND
DL --> AND
AND --> R
The problems londec solves
Rules stored in a database, not compiled into code
Eligibility and scheduling logic tend to end up hardcoded: changing who qualifies for a feature requires a code change and a deploy. londec conditions are plain Python dicts — JSON-serializable, storable in a database, editable through a UI, and passed to londec.decide at runtime. The logic lives in data, not in a release.
# Stored in DB, loaded at runtime — no deploy needed to update the rule
condition = {
"type": "AND",
"list": [
{"type": "event_happened", "activity_id": "onboarding_complete"},
{"type": "event_happened_fewer_than", "activity_id": "invoice_sent", "x": 3},
{"type": "available_on_date_range", "start_date": "2026-01-01", "end_date": "2026-06-30", "timezone_offset": 120},
]
}
result = londec.decide(condition, events=user_events, field_map=FIELD_MAP)
This pattern fits naturally wherever rules vary per tenant, per plan, or per campaign — and need to be updated without touching application code.
When — not just whether
A boolean answer is often not enough. If a rule is not yet satisfied, a scheduler needs to know when to check again. londec returns a Decision(satisfied, when): satisfied is always an explicit, directly-computed bool — never inferred by comparing when to now after the fact — and when is an optional timestamp for lookahead/display:
result = londec.decide(
{"type": "delay", "activity_id": "trial_started", "days": 14},
events=user_events,
field_map=FIELD_MAP,
)
# Decision(False, None) → trial never started; nothing to schedule
# Decision(False, datetime(...)) → trial started, but the 14-day window hasn't opened yet;
# `when` is the date it will
# Decision(True, datetime(...)) → the window is open, since `when`
This makes londec useful not just for access control ("is this user eligible right now?") but for proactive scheduling ("when should this rule next be evaluated or triggered?").
Use cases that benefit from this:
- Drip campaigns — send a follow-up exactly N days after a user completed a step
- Trial-to-paid conversion — trigger an upsell prompt the moment a trial window closes
- Loyalty rewards — unlock a reward tier as soon as a user's Nth qualifying action is recorded
- Time-gated content — open the next module precisely when a prerequisite period has elapsed
- Deployment pipelines — schedule a production deploy for the earliest moment all gates are clear
Composable conditions with datetime propagation
Conditions compose into AND/OR trees. satisfied is always computed via genuine all()/any() over each child's own satisfied flag; when is aggregated separately, only among children that have one — so the result remains useful for scheduling without ever inferring satisfaction from a date comparison:
AND(MAX_AND) — satisfied when the last prerequisite is met; reports the latestwhenMIN_AND— same satisfaction asAND; reports the earliestwhen(useful to know when the first prerequisite was met)OR(MIN_OR) — satisfied as soon as any branch is; reports the earliestwhenMAX_OR— same satisfaction asOR; reports the latestwhenacross satisfied branches
# "Eligible for a loyalty reward after placing 3 orders AND waiting 30 days since the first"
condition = {
"type": "AND",
"list": [
{"type": "event_happened_at_least", "activity_id": "order_placed", "x": 3},
{"type": "delay", "activity_id": "order_placed", "days": 30},
]
}
Nesting is unrestricted — OR inside AND, AND inside OR — allowing arbitrarily complex rules without writing new evaluator code.
Checking data values across the event history
payload_match checks the value of any key in the event dict against an expected answer. It operates on the most recent event by default, or at a specific position in the event history via seq_num.
# "The most recent assessment event has a risk_score of at least 7"
{"type": "payload_match", "activity_id": "assessment", "key": "risk_score", "answer": "7", "sub_type": "gte"}
# "The second-most-recent check-in had an engagement score below 3"
{"type": "payload_match", "activity_id": "check_in", "key": "engagement", "answer": "3", "sub_type": "lt", "seq_num": 1}
# "The most recent event of any type flagged the account for review"
# (no activity_id → checks across all event types)
{"type": "payload_match", "key": "review_flag", "answer": "true"}
seq_num counts from the most recent: 0 (default) is the latest, 1 is the second latest, and so on. Revoked events are excluded before indexing.
Domain-agnostic — works with any flat event structure
londec has no opinion about your data model. Events are plain dicts; all keys londec needs must be present at the root level. You tell londec which keys carry the meaningful values through a FieldMap:
from londec import FieldMap
FIELD_MAP = FieldMap(
type_id="event_type", # key that identifies the event type
created_at="occurred_at", # key holding the event timestamp
revoked_at="cancelled_at" # key holding the cancellation timestamp (None = active)
)
All other keys in the event dict — whether raw data, computed metrics, or anything else — are accessed directly by name in payload_match conditions. The caller is responsible for flattening nested structures and resolving any key collisions before passing events to londec.
No I/O, no ORM, no framework
londec operates on a list[dict]. It has no database queries, no ORM models, no HTTP calls. The caller is responsible for fetching and preparing the event list; londec is responsible for evaluating the condition tree.
This makes londec straightforward to test — no database setup, no mocks, no fixtures beyond a list of plain dicts:
def test_eligible_after_onboarding_delay():
events = [
{"event_type": "onboarding_complete", "occurred_at": datetime(2026, 3, 1, tzinfo=UTC), "cancelled_at": None},
]
result = londec.decide(
{"type": "delay", "activity_id": "onboarding_complete", "days": 7},
events=events,
field_map=FIELD_MAP,
now=datetime(2026, 3, 8, tzinfo=UTC),
)
assert result == Decision(True, datetime(2026, 3, 8, tzinfo=UTC))
Core concepts
Events
A list[dict] sorted oldest-first. londec requires flat dicts — all keys it needs to read must be at the root level. The caller is responsible for flattening nested structures before passing events to londec.
events = [
{
"event_type": "account_created",
"occurred_at": datetime(2026, 1, 1, tzinfo=UTC),
"cancelled_at": None,
"plan": "trial",
},
{
"event_type": "order_placed",
"occurred_at": datetime(2026, 1, 10, tzinfo=UTC),
"cancelled_at": None,
"amount": 49.00,
"currency": "EUR",
"lifetime_value": 49.00,
"order_count": 1,
},
{
"event_type": "order_placed",
"occurred_at": datetime(2026, 2, 3, tzinfo=UTC),
"cancelled_at": None,
"amount": 79.00,
"currency": "EUR",
"lifetime_value": 128.00,
"order_count": 2,
},
]
A different system might use entirely different field names — londec works the same way, as long as the FieldMap describes the structure:
events = [
{
"kind": "build",
"ts": datetime(2026, 3, 10, 9, 0, tzinfo=UTC),
"reverted_at": None,
"branch": "main",
"triggered_by": "push",
"coverage": 91,
"lint_errors": 0,
},
{
"kind": "deploy",
"ts": datetime(2026, 3, 10, 9, 45, tzinfo=UTC),
"reverted_at": None,
"environment": "staging",
"version": "2.1.0",
"smoke_passed": True,
"response_time_ms": 210,
},
]
FIELD_MAP = FieldMap(
type_id="kind",
created_at="ts",
revoked_at="reverted_at",
)
FieldMap
Maps three semantic roles to key names in your flat event dicts:
type_id— the key that identifies what kind of event this iscreated_at— the key holding the event timestamprevoked_at— the key holding the cancellation/revocation timestamp (Nonemeans the event is active)
All other event data — raw values, computed metrics, flags — is accessed directly by key name in payload_match conditions.
Condition
A plain dict with a "type" key. If "type" is absent, the condition is treated as unconditionally satisfied (True). Composite conditions use "list" to hold sub-conditions.
Result: Decision(satisfied, when)
class Decision(NamedTuple):
satisfied: bool
when: datetime.datetime | None = None
satisfied is always computed directly by whichever evaluator or combinator produced it — never inferred by comparing when to now after the fact. when, when present, means "since this date" if satisfied is True, or "predicted to become satisfied at this date" if satisfied is False. It's None whenever there's no meaningful date to report — a pure count-based check (e.g. event_not_happened), or a genuine dead end with no way to predict a future resolution.
Decision(False, None)— not satisfied, nothing to predictDecision(False, datetime(...))— not satisfied yet, but predicted to become satisfied at this date (e.g. adelaywhose threshold hasn't been reached)Decision(True, None)— satisfied, no timestamp available (e.g.event_happened_fewer_than)Decision(True, datetime(...))— satisfied since this date
Condition reference
Event occurrence
| Type | Satisfied when | when |
|---|---|---|
event_happened |
At least one non-revoked event of activity_id exists |
created_at of the most recent |
event_not_happened |
No non-revoked events of activity_id exist |
None |
event_happened_exactly |
Exactly x non-revoked events of activity_id |
created_at of the most recent |
event_happened_fewer_than |
Fewer than x non-revoked events |
None |
event_happened_at_least |
x or more non-revoked events |
None |
event_revoked |
A revoked event of activity_id exists |
revoked_at of the most recent revoked event |
Timing
| Type | Fields | Satisfied when | when |
|---|---|---|---|
delay |
activity_id, days |
now >= created_at + days |
created_at + days — the threshold, whether or not it's been reached yet |
is_taken_recently |
activity_id, duration_type ("days"/"hours"), duration |
Most recent event is within duration of now |
created_at of that event if satisfied, else None (a closing condition — no future re-opening to predict) |
available_on_date_range |
start_date, end_date (ISO), timezone_offset (minutes) |
now is within the date range |
start_date (as datetime) before/during the range; None once the range has closed |
Payload matching
payload_match checks the value at key in the matching event dict. activity_id is optional — when omitted, the condition checks the Nth most recent event across all event types.
seq_num (int or string, default 0) selects the event position: 0 is the most recent, 1 the second most recent, and so on. Revoked events are excluded before indexing.
| Field | Required | Description |
|---|---|---|
key |
Yes | Key to look up in the event dict |
answer |
Yes | Expected value |
activity_id |
No | Filter to events of this type only |
sub_type |
No | Expression evaluator (see below); defaults to strict equality |
seq_num |
No | Position index (default 0 = most recent) |
Sequence
| Type | Fields | Satisfied when | when |
|---|---|---|---|
last_event_type_equals |
activity_id, seq_num |
The event at position seq_num (most-recent-first) has activity_id |
created_at of that event |
Combinators
satisfied is always computed via genuine all()/any() over each child's own satisfied flag; when is aggregated separately, only among children that have one.
| Type | Alias | satisfied when |
when (once satisfied) |
|---|---|---|---|
AND |
MAX_AND |
Every child is satisfied | latest among children's when |
MIN_AND |
— | Every child is satisfied (same as AND) |
earliest among children's when |
OR |
MIN_OR |
Any child is satisfied | earliest among satisfied children's when |
MAX_OR |
— | Any child is satisfied (same as OR) |
latest among satisfied children's when |
sub_type expression evaluators
Used with payload_match to perform comparisons beyond strict equality.
sub_type |
Comparison |
|---|---|
equals |
Numeric equality, falls back to string equality |
ne |
Not equal (string comparison) |
gt / gte |
Numeric greater-than / greater-than-or-equal |
lt / lte |
Numeric less-than / less-than-or-equal |
in |
Value is in a list |
in_range / not_in_range |
Value within / outside [min, max] |
contains_any_of |
Collection contains at least one of the given items |
contains_none_of |
Collection contains none of the given items |
contains_all_of |
Collection contains all of the given items |
is_subset_of |
Value (as set) is a subset of the given set |
is_not_subset_of |
Value (as set) is not a subset of the given set |
true / false |
Value is truthy / falsy |
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file londec-0.2.0.tar.gz.
File metadata
- Download URL: londec-0.2.0.tar.gz
- Upload date:
- Size: 16.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b288c997e29cf05448bc469ecf56b28c94795fe27a2d35fd725c45a305bfc7bb
|
|
| MD5 |
cce959bc65af48cc837c787786575975
|
|
| BLAKE2b-256 |
6b26bfe5e23b25e79ccdb1b89d9a073e3460e0d57cec1daa684698816213e148
|
File details
Details for the file londec-0.2.0-py3-none-any.whl.
File metadata
- Download URL: londec-0.2.0-py3-none-any.whl
- Upload date:
- Size: 12.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21f1371835948001cbb37fd4544616c85b65c5421907b08dec6f3ce6a3299b67
|
|
| MD5 |
116f9c9d5b643688f46ad5f8b661e616
|
|
| BLAKE2b-256 |
f490ce593c20b610d3c86f6a198cc3dc8439cf2af496d42033a11cce7b4bc923
|