popoto.fields.cyclic_decay_field¶
popoto.fields.cyclic_decay_field
¶
CyclicDecayField — Temporal Rhythms + Homeostatic Pressure.
Extends DecayingSortedField with two additional temporal forces computed atomically in the same Lua script:
-
Cyclical resonance: Periodic boosts following cosine curves. A record about Q1 renewals resurfaces every January.
-
Homeostatic pressure: Urgency that builds linearly over time when an item goes unresolved. Discharged by
resolve_pressure().
The effective score is: decay + cyclic_resonance + pressure
When cycles=[] and pressure_rate=0.0, behavior is identical to
DecayingSortedField (the Lua script short-circuits on nil HGET lookups).
Companion Redis hashes store per-member cycle and pressure data
$CyclicDecayF:{Model}:{field}:{partitions}:cycles— msgpack cycle tuples,[period, amplitude, phase]or, once a member has saved under this field (#698),[period, amplitude, phase, declared_baseline]. The optional 4th slot records the declared amplitude in force when the entry was last written, lettingon_savetell "the developer edited the declaration" from "learning diverged from the declaration" — seeCyclicDecayField.on_save.$CyclicDecayF:{Model}:{field}:{partitions}:pressure— msgpack pressure dict
Example
class Directive(Model): agent_id = KeyField() content = Field(type=str) relevance = CyclicDecayField( decay_rate=0.5, cycles=[(TemporalPeriod.QUARTERLY, 5.0, 0)], pressure_rate=0.1, )
top = Directive.query.filter(agent_id="agent-1").top_by_decay("relevance", n=10) directive.resolve_pressure("relevance")
CyclicDecayField
¶
Bases: DecayingSortedField
A DecayingSortedField with cyclical resonance and homeostatic pressure.
Extends the parent's power-law decay with two additional forces:
-
Cyclical resonance via
cyclesparameter: each cycle is a(period, amplitude, phase)tuple defining a cosine curve. The resonance contribution isamplitude * cos(2*pi*(now-phase)/period). -
Homeostatic pressure via
pressure_rate: linearly increasing urgency. Pressure =pressure_rate * unresolved_days. Reset by callingmodel.resolve_pressure(field_name).
When cycles=[] and pressure_rate=0.0, behavior is identical
to DecayingSortedField.
Ranking is deterministic: equal effective-scored members are ordered by member key (redis_key) ascending, byte-wise, broken inside the Lua script before top-N truncation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
decay_rate
|
Controls how fast scores drop. Higher = faster decay. Default 0.5. Must be > 0. (Inherited from DecayingSortedField.) |
required | |
base_score_field
|
Name of a companion field whose value multiplies the decay curve. When None, base score is 1.0. (Inherited.) |
required | |
cycles
|
List of |
required | |
pressure_rate
|
Rate at which urgency builds per unresolved day.
Default |
required | |
partition_by
|
Partition the sorted set by key field values. Inherited from SortedFieldMixin. |
required |
Example
from popoto.fields.constants import TemporalPeriod
class Directive(Model): agent_id = KeyField() content = Field(type=str) relevance = CyclicDecayField( decay_rate=0.5, cycles=[(TemporalPeriod.QUARTERLY, 5.0, 0)], pressure_rate=0.1, )
Source code in src/popoto/fields/cyclic_decay_field.py
435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 | |
export_state(model_instance, field_name, field_value, **kwargs)
classmethod
¶
Export the per-member cycles and pressure companion data.
Returns:
| Type | Description |
|---|---|
|
|
|
|
with either key omitted when that companion hash has no entry for |
|
|
this instance, or |
Source code in src/popoto/fields/cyclic_decay_field.py
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 | |
import_state(model_instance, field_name, state, **kwargs)
classmethod
¶
Restore per-member cycles and pressure companion data after import.
Ordering note -- the transfer driver calls import_state after
save(), and that is still required, though since #679 the reason
has narrowed.
on_save no longer clobbers learned amplitudes; it preserves
whatever is already stored for the member. But on an import the record
is new, so there is nothing stored yet and on_save legitimately
writes the class-level defaults -- and it seeds
pressure.last_resolved to now whenever the entry is fresh,
which is exactly the value the import must replace. So these writes
still have to land on top of on_save's, and inverting the order
would still silently discard both the imported amplitudes and the
accumulated pressure age.
Cycle entries deliberately do not carry the declared-baseline slot
(#698). A stored cycle entry may have a 4th element,
declared_baseline — the declared amplitude in force in the
deployment that wrote the entry (see on_save). That is
deployment-local by definition, so this method rebuilds every cycle as
a 3-element [period, amplitude, phase] and never carries slot 3
through, even when the exported state has it. Carrying the exporter's
baseline into a target whose field.cycles declares a different
amplitude for that period would make the first post-import save()
see baseline != declared and fire a reset — destroying exactly the
learned amplitude roundtrip_policy = "carry" exists to preserve,
with no import-time signal. Dropping it instead means the imported
entry is "baseline unknown": it preserves the learned amplitude
unconditionally and acquires a fresh baseline, from the importing
deployment's declaration, on its next ordinary save. The cost is one
missed detection window if the declaration was also edited between
export and that first post-import save — the same trade already
accepted for a pre-upgrade record (see on_save's Risk 2 in
docs/plans/sdlc-698.md) and self-healing the same way.
Source code in src/popoto/fields/cyclic_decay_field.py
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 | |
get_cycles_hash_key(model_instance, field_name)
¶
Build the Redis key for the cycles companion hash.
Public API for external callers that need direct Redis access to cycle data (e.g., bulk inspection, custom cycle updates, monitoring).
Pattern: $CyclicDecayF:{Model}:{field}:{partitions}:cycles
Source code in src/popoto/fields/cyclic_decay_field.py
get_pressure_hash_key(model_instance, field_name)
¶
Build the Redis key for the pressure companion hash.
Public API for external callers that need direct Redis access to pressure data (e.g., bulk pressure resets, monitoring dashboards).
Pattern: $CyclicDecayF:{Model}:{field}:{partitions}:pressure
Source code in src/popoto/fields/cyclic_decay_field.py
rank_decayed(zset_key, *, now, n=None, confidence=None, validity=None, decay_rate=None, base_score_field=None)
¶
Evaluate the cyclic decay script over one sorted set (#648).
Overrides :meth:DecayingSortedField.rank_decayed because this fork of
the decay math uses an incompatible KEYS layout: cycles at
KEYS[2] and pressure at KEYS[3], which pushes the confidence
hash to KEYS[4]. Both scripts carry a comment forbidding a "unify"
on KEYS[2] -- reusing index 2 here would cmsgpack.unpack the
cycles array as a confidence dict, a silent corrupt read rather than a
clean crash. Keeping the two layouts in two class bodies, rather than
behind a flag in one, is what makes that mistake unavailable instead of
merely discouraged.
The companion hash keys are the partition ZSET key plus a suffix (the
same derivation as :meth:get_cycles_hash_key /
:meth:get_cycles_hash_key_from_parts), so they follow from
zset_key alone. The confidence hash does not -- it lives under its
own $ConfidencF: prefix -- so it arrives resolved in confidence.
validity is accepted and deliberately ignored: KEYS 1-4 are
taken here and the script's header forbids renumbering, so this script
has no validity gate. That gap is an explicit No-Go, pinned by
tests/test_validity_field.py::TestCyclicDecayGatingGap and recorded
under "Known limitations" in
docs/features/validity-and-supersession.md. The parameter is kept in
the signature so callers stay polymorphic; if you ever gate this script,
update all three places.
Args and return value are otherwise as
:meth:DecayingSortedField.rank_decayed.
Source code in src/popoto/fields/cyclic_decay_field.py
get_cycles_hash_key_from_parts(model_class, field_name, *partition_values)
classmethod
¶
Build cycles hash key from model class and explicit partition values.
Public API for query paths and external callers that have partition values but not a model instance.
Source code in src/popoto/fields/cyclic_decay_field.py
get_pressure_hash_key_from_parts(model_class, field_name, *partition_values)
classmethod
¶
Build pressure hash key from model class and explicit partition values.
Public API for query paths and external callers that have partition values but not a model instance.
Source code in src/popoto/fields/cyclic_decay_field.py
on_save(model_instance, field_name, field_value, pipeline=None, **kwargs)
classmethod
¶
Store timestamp (parent) then store cycle/pressure companion data.
Both companion hashes follow the same rule: declared parameters are refreshed from the field; learned state is preserved.
For cycles, period and phase are declarative and re-read from
field.cycles on every save. amplitude is learned (mutated by
strengthen_cycle / weaken_cycle) but is now merged with a
three-way rule rather than a two-way one (#698): each stored entry
may carry an optional 4th slot, declared_baseline — the declared
amplitude that was in force the last time on_save wrote this entry.
Comparing the incoming declared amplitude against that baseline (not
against the learned value) distinguishes "the developer edited the
declaration" from "learning diverged from the declaration":
- baseline absent (a legacy 3-element entry, or a non-numeric slot 3) → "baseline unknown" → preserve the learned amplitude exactly as before #698, and record the declared amplitude as the new baseline.
- baseline equals the incoming declared amplitude → the declaration has not moved → preserve the learned amplitude, re-record the same baseline.
- baseline differs from the incoming declared amplitude → the developer
edited the declaration → discard the learned amplitude, adopt the
declared value, record it as the new baseline, and emit one
logger.infonaming the model, field, member key, period, old baseline, new declared value and the discarded learned amplitude.
The comparison is exact float equality, never a tolerance — both sides are the same Python float, round-tripped through msgpack, which preserves IEEE doubles exactly. Stored cycles are matched to declared cycles by period, FIFO within duplicate periods, with the amplitude and its baseline popped together as one decision; a declared period with nothing stored takes the declared amplitude (baseline unknown), and a stored period no longer declared is dropped. Before #679 this branch overwrote the whole entry with the declared defaults, silently erasing everything the strengthen/weaken calls had accumulated; #679 then made the merge unconditional the other way, so an edited declaration could never win. #698 adds the missing third input (the baseline) so the merge can tell the two cases apart. A record with no recorded baseline needs two saves to honor an edit made after this change ships: the first save records the baseline, and only an edit made after that save is detected — see the field's module docs / docs/features/cyclic-decay-field.md.
For pressure, rate is declarative and last_resolved is learned:
on first save (no existing entry) the full dict is written with
last_resolved=now; on subsequent saves only the rate is updated.
Both companion hashes are read-modified-written by one Lua script,
CYCLES_MERGE_LUA (#699), run eagerly against the live connection
regardless of a caller-supplied pipeline — the merge decision
(which amplitude/rate wins) has to be known synchronously to emit the
698 reset log, and a pipeline-queued script's return value is not¶
available until execute(), which save() does not surface.
Running it eagerly is also what closes the race this exists for:
without it, two concurrent save() calls — or a save() racing
strengthen_cycle()/weaken_cycle()/resolve_pressure() —
each read-then-write the hash independently and the last writer
clobbers the other's update; the script makes the whole
read-decide-write sequence one atomic server-side step.
Source code in src/popoto/fields/cyclic_decay_field.py
786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 | |
on_delete(model_instance, field_name, field_value, pipeline=None, **kwargs)
classmethod
¶
Remove companion hash entries then delegate to parent.