class_name WorldEventRetentionPlanner extends RefCounted const REASON_PINNED_FACT := "pinned_fact" const REASON_EXPLICIT_CAUSAL_FACT := "explicit_causal_fact" const REASON_CAUSAL_ANCESTOR := "causal_ancestor" const REASON_WITHIN_RAW_RETENTION := "within_raw_retention" const REASON_NOT_ROLLUP_ELIGIBLE := "not_rollup_eligible" const REASON_ROLLUP_PAYLOAD_INVALID := "rollup_payload_invalid" const REASON_ROLLED_UP_DAILY := "rolled_up_daily" var _last_error := "" var _last_telemetry: Dictionary = {} func plan( source: WorldEventStore, current_tick: int, ticks_per_day: int, raw_retention_days: int, high_volume_event_types: Array, pinned_fact_ids: Array = [], causal_fact_ids: Array = [] ) -> WorldEventRetentionPlan: _last_error = "" _last_telemetry = { "succeeded": false, "input_event_count": source.size() if source != null else 0, "error": "", } if source == null: return _fail("source WorldEventStore is null") if current_tick < 0 or ticks_per_day <= 0 or raw_retention_days < 0: return _fail("retention time configuration is invalid") var normalized_types: Variant = _normalize_event_types(high_volume_event_types) var normalized_pins: Variant = _normalize_event_ids(pinned_fact_ids) var normalized_causal_facts: Variant = _normalize_event_ids(causal_fact_ids) if normalized_types == null: return _fail("high-volume event types are invalid or duplicated") if normalized_pins == null: return _fail("pinned fact IDs are invalid or duplicated") if normalized_causal_facts == null: return _fail("causal fact IDs are invalid or duplicated") for event_id: int in normalized_pins: if not source.has_event(event_id): return _fail("pinned fact ID %d is missing from the source store" % event_id) for event_id: int in normalized_causal_facts: if not source.has_event(event_id): return _fail("causal fact ID %d is missing from the source store" % event_id) var source_serialized := JSON.stringify(source.to_dictionary()) var source_checksum := source_serialized.sha256_text() var events := source.get_all() var events_by_id: Dictionary = {} var order_by_id: Dictionary = {} var traces_by_id: Dictionary = {} for event_index in range(events.size()): var event := events[event_index] as WorldEventRecord if event == null or event.get_tick() > current_tick: return _fail("source contains a null or future event") var event_id := event.get_event_id() events_by_id[event_id] = event order_by_id[event_id] = event_index var cause_ids: Variant = _extract_cause_event_ids(event.get_payload()) if cause_ids == null: return _fail("event %d has an invalid causal fact contract" % event_id) traces_by_id[event_id] = { "event_id": event_id, "decision": "", "reasons": [], "cause_event_ids": cause_ids, "required_by_event_ids": [], "rollup_keys": [], } for event: WorldEventRecord in events: var event_id := event.get_event_id() var trace: Dictionary = traces_by_id[event_id] for cause_event_id: int in trace["cause_event_ids"]: if not events_by_id.has(cause_event_id): return _fail( "event %d references missing causal fact %d" % [event_id, cause_event_id] ) if int(order_by_id[cause_event_id]) >= int(order_by_id[event_id]): return _fail( "event %d references non-prior causal fact %d" % [event_id, cause_event_id] ) var high_volume_type_set: Dictionary = {} for event_type: StringName in normalized_types: high_volume_type_set[String(event_type)] = true var current_day_index := int(current_tick / ticks_per_day) var first_raw_day_index := maxi(current_day_index - raw_retention_days, 0) var retained: Dictionary = {} var contributions_by_event_id: Dictionary = {} for event: WorldEventRecord in events: var event_id := event.get_event_id() var event_day_index := int(event.get_tick() / ticks_per_day) if event_day_index >= first_raw_day_index: _mark_retained(retained, traces_by_id, event_id, REASON_WITHIN_RAW_RETENTION) continue if not high_volume_type_set.has(String(event.get_event_type())): _mark_retained(retained, traces_by_id, event_id, REASON_NOT_ROLLUP_ELIGIBLE) continue var contributions: Variant = _extract_rollup_contributions(event, event_day_index) if contributions == null: _mark_retained(retained, traces_by_id, event_id, REASON_ROLLUP_PAYLOAD_INVALID) continue contributions_by_event_id[event_id] = contributions for event_id: int in normalized_pins: _mark_retained(retained, traces_by_id, event_id, REASON_PINNED_FACT) for event_id: int in normalized_causal_facts: _mark_retained(retained, traces_by_id, event_id, REASON_EXPLICIT_CAUSAL_FACT) for event: WorldEventRecord in events: var required_by_id := event.get_event_id() var child_trace: Dictionary = traces_by_id[required_by_id] for cause_event_id: int in child_trace["cause_event_ids"]: _mark_retained(retained, traces_by_id, cause_event_id, REASON_CAUSAL_ANCESTOR) var cause_trace: Dictionary = traces_by_id[cause_event_id] var required_by_ids: Array = cause_trace["required_by_event_ids"] if required_by_id not in required_by_ids: required_by_ids.append(required_by_id) var accumulators: Dictionary = {} var rolled_up: Dictionary = {} for event: WorldEventRecord in events: var event_id := event.get_event_id() if retained.has(event_id): continue if not contributions_by_event_id.has(event_id): return _fail("an event selected for rollup has no validated contribution") var trace: Dictionary = traces_by_id[event_id] _add_reason(trace, REASON_ROLLED_UP_DAILY) for contribution: Dictionary in contributions_by_event_id[event_id]: var rollup_key := WorldEventDailyRollupRecord.index_key_for( int(contribution["day_index"]), StringName(contribution["world_id"]), StringName(contribution["location_id"]), StringName(contribution["event_type"]), StringName(contribution["item_id"]) ) if not accumulators.has(rollup_key): accumulators[rollup_key] = { "day_index": int(contribution["day_index"]), "world_id": String(contribution["world_id"]), "location_id": String(contribution["location_id"]), "event_type": String(contribution["event_type"]), "item_id": String(contribution["item_id"]), "source_event_ids": [], "total_amount": 0.0, "minimum_tick": event.get_tick(), "maximum_tick": event.get_tick(), } var accumulator: Dictionary = accumulators[rollup_key] var source_event_ids: Array = accumulator["source_event_ids"] source_event_ids.append(event_id) accumulator["total_amount"] = ( float(accumulator["total_amount"]) + float(contribution["amount"]) ) accumulator["minimum_tick"] = mini(int(accumulator["minimum_tick"]), event.get_tick()) accumulator["maximum_tick"] = maxi(int(accumulator["maximum_tick"]), event.get_tick()) var trace_rollup_keys: Array = trace["rollup_keys"] trace_rollup_keys.append(rollup_key) rolled_up[event_id] = true var rollup_store := WorldEventDailyRollupStore.new() var accumulator_keys: Array = accumulators.keys() accumulator_keys.sort() for rollup_key: String in accumulator_keys: var accumulator: Dictionary = accumulators[rollup_key] var record := WorldEventDailyRollupRecord.create( int(accumulator["day_index"]), StringName(accumulator["world_id"]), StringName(accumulator["location_id"]), StringName(accumulator["event_type"]), StringName(accumulator["item_id"]), _copy_int_array(accumulator["source_event_ids"]), float(accumulator["total_amount"]), int(accumulator["minimum_tick"]), int(accumulator["maximum_tick"]) ) if record == null or not rollup_store.append(record): return _fail("daily rollup record construction failed") var retained_ids := _sorted_int_keys(retained) var rolled_up_ids := _sorted_int_keys(rolled_up) var trace_event_ids := _sorted_int_keys(traces_by_id) var reason_traces: Array[Dictionary] = [] for event_id: int in trace_event_ids: var trace: Dictionary = traces_by_id[event_id] var reasons: Array = trace["reasons"] var required_by_ids: Array = trace["required_by_event_ids"] var rollup_keys: Array = trace["rollup_keys"] reasons.sort() required_by_ids.sort() rollup_keys.sort() trace["decision"] = ( WorldEventRetentionPlan.DECISION_RETAIN_RAW if retained.has(event_id) else WorldEventRetentionPlan.DECISION_ROLLED_UP ) reason_traces.append(trace.duplicate(true)) var source_unchanged := JSON.stringify(source.to_dictionary()) == source_serialized if not source_unchanged: return _fail("source WorldEventStore changed while planning") var telemetry := { "succeeded": true, "input_event_count": events.size(), "retained_event_count": retained_ids.size(), "rolled_up_event_count": rolled_up_ids.size(), "rollup_record_count": rollup_store.size(), "rollup_contribution_count": _rollup_contribution_count(rollup_store), "pinned_fact_count": normalized_pins.size(), "causal_fact_count": normalized_causal_facts.size(), "causal_ancestor_count": _reason_count(reason_traces, REASON_CAUSAL_ANCESTOR), "recent_event_count": _reason_count(reason_traces, REASON_WITHIN_RAW_RETENTION), "non_rollup_event_count": _reason_count(reason_traces, REASON_NOT_ROLLUP_ELIGIBLE), "invalid_rollup_event_count": _reason_count(reason_traces, REASON_ROLLUP_PAYLOAD_INVALID), "first_raw_day_index": first_raw_day_index, "source_checksum": source_checksum, "source_unchanged": source_unchanged, } var result := WorldEventRetentionPlan.create( current_tick, ticks_per_day, raw_retention_days, first_raw_day_index, source_checksum, _copy_string_name_array(normalized_types), _copy_int_array(normalized_pins), _copy_int_array(normalized_causal_facts), retained_ids, rolled_up_ids, rollup_store, reason_traces, telemetry ) if result == null: return _fail("retention plan validation failed") _last_telemetry = telemetry.duplicate(true) return result func get_last_error() -> String: return _last_error func get_last_telemetry() -> Dictionary: return _last_telemetry.duplicate(true) func _fail(message: String) -> WorldEventRetentionPlan: _last_error = message _last_telemetry["error"] = message _last_telemetry["succeeded"] = false return null static func _mark_retained( retained: Dictionary, traces_by_id: Dictionary, event_id: int, reason: String ) -> void: retained[event_id] = true var trace: Dictionary = traces_by_id[event_id] _add_reason(trace, reason) static func _add_reason(trace: Dictionary, reason: String) -> void: var reasons: Array = trace["reasons"] if reason not in reasons: reasons.append(reason) static func _extract_cause_event_ids(payload: Dictionary) -> Variant: var seen: Dictionary = {} var event_ids: Array[int] = [] if payload.has("cause_event_id"): var single_cause: Variant = payload["cause_event_id"] if not single_cause is int or int(single_cause) < -1: return null if int(single_cause) >= 0: seen[int(single_cause)] = true event_ids.append(int(single_cause)) if payload.has("cause_event_ids"): var multiple_causes: Variant = payload["cause_event_ids"] if not multiple_causes is Array: return null for raw_cause: Variant in multiple_causes: if not raw_cause is int or int(raw_cause) < 0: return null if seen.has(int(raw_cause)): continue seen[int(raw_cause)] = true event_ids.append(int(raw_cause)) event_ids.sort() return event_ids static func _extract_rollup_contributions(event: WorldEventRecord, day_index: int) -> Variant: if event == null or not event.has_location(): return null var location := event.get_location() if ( location == null or location.get_world_id().is_empty() or location.get_location_id().is_empty() ): return null var payload := event.get_payload() var has_item_amount := payload.has("item_id") or payload.has("amount") var has_cargo_ledger := payload.has("cargo_ledger") if has_item_amount and has_cargo_ledger: return null var contributions: Array[Dictionary] = [] if has_cargo_ledger: if not payload["cargo_ledger"] is Dictionary: return null var ledger: Dictionary = payload["cargo_ledger"] var source_keys: Dictionary = {} for raw_item_id: Variant in ledger: if not raw_item_id is String and not raw_item_id is StringName: return null var item_id := String(raw_item_id) if item_id.is_empty() or source_keys.has(item_id): return null var raw_amount: Variant = ledger[raw_item_id] if ( not raw_amount is int and not raw_amount is float or not is_finite(float(raw_amount)) or float(raw_amount) <= 0.0 ): return null source_keys[item_id] = raw_item_id var item_ids: Array = source_keys.keys() item_ids.sort() for item_id: String in item_ids: contributions.append( _contribution( event, location, day_index, item_id, float(ledger[source_keys[item_id]]) ) ) elif payload.has("item_id") and payload.has("amount"): var raw_item_id: Variant = payload["item_id"] var raw_amount: Variant = payload["amount"] if ( (not raw_item_id is String and not raw_item_id is StringName) or String(raw_item_id).is_empty() or (not raw_amount is int and not raw_amount is float) or not is_finite(float(raw_amount)) or float(raw_amount) <= 0.0 ): return null contributions.append( _contribution(event, location, day_index, String(raw_item_id), float(raw_amount)) ) else: return null return contributions if not contributions.is_empty() else null static func _contribution( event: WorldEventRecord, location: SpatialAddress, day_index: int, item_id: String, amount: float ) -> Dictionary: return { "day_index": day_index, "world_id": String(location.get_world_id()), "location_id": String(location.get_location_id()), "event_type": String(event.get_event_type()), "item_id": item_id, "amount": amount, } static func _normalize_event_types(values: Array) -> Variant: var seen: Dictionary = {} var normalized: Array[StringName] = [] for value: Variant in values: if (not value is String and not value is StringName) or StringName(value).is_empty(): return null var event_type := StringName(value) if seen.has(String(event_type)): return null seen[String(event_type)] = true normalized.append(event_type) normalized.sort_custom( func(first: StringName, second: StringName) -> bool: return String(first) < String(second) ) return normalized static func _normalize_event_ids(values: Array) -> Variant: var seen: Dictionary = {} var normalized: Array[int] = [] for value: Variant in values: if not value is int or int(value) < 0 or seen.has(int(value)): return null seen[int(value)] = true normalized.append(int(value)) normalized.sort() return normalized static func _sorted_int_keys(values: Dictionary) -> Array[int]: var result: Array[int] = [] for value: Variant in values: result.append(int(value)) result.sort() return result static func _copy_int_array(values: Array) -> Array[int]: var result: Array[int] = [] for value: Variant in values: result.append(int(value)) return result static func _copy_string_name_array(values: Array) -> Array[StringName]: var result: Array[StringName] = [] for value: Variant in values: result.append(StringName(value)) return result static func _reason_count(traces: Array[Dictionary], reason: String) -> int: var count := 0 for trace: Dictionary in traces: var reasons: Array = trace["reasons"] if reason in reasons: count += 1 return count static func _rollup_contribution_count(store: WorldEventDailyRollupStore) -> int: var count := 0 for record: WorldEventDailyRollupRecord in store.get_all(): count += record.get_event_count() return count