Skip to main content

cairn_mod/labels/
emission.rs

1//! Pure-function action→label translation (§F21, #59).
2//!
3//! Turns an in-flight moderation action plus the operator's resolved
4//! [`LabelEmissionPolicy`] into
5//! a set of unsigned [`LabelDraft`]s. The recorder (#60) takes the
6//! drafts, signs them via the existing labeler signing path, and
7//! persists the resulting label rows alongside the
8//! `subject_actions` row in one transaction.
9//!
10//! Same shape as the v1.4 calculators (#49 strike, #50 decay, #51
11//! window): no I/O, no async, no signing, no DB. Heavy unit-test
12//! coverage lives inline.
13//!
14//! # Decision rules
15//!
16//! [`resolve_action_labels`]:
17//!
18//! 1. `policy.enabled = false` → empty vec.
19//! 2. [`LabelEmissionPolicy::resolve_action_label`] returns `None`
20//!    (note never emits; warning gated on `warning_emits_label`;
21//!    every other type emits its default or override) → empty vec.
22//! 3. Otherwise → exactly one [`LabelDraft`]. The vec shape is
23//!    forward-compatible with future per-action-multi-label
24//!    surfaces (e.g., emit both a takedown label and an
25//!    institutional-policy label); v1.5 ships exactly one entry
26//!    per action.
27//!
28//! [`resolve_reason_labels`]:
29//!
30//! 1. `policy.enabled = false` → empty vec.
31//! 2. `policy.emit_reason_labels = false` → empty vec.
32//! 3. `action.reason_codes` empty → empty vec.
33//! 4. `action.action_type == Note` → empty vec. Notes never emit
34//!    labels of any kind.
35//! 5. `action.action_type == Warning && !policy.warning_emits_label`
36//!    → empty vec. The action label and reason labels share their
37//!    suppression gate: a warning whose action label is suppressed
38//!    can't surface reason labels alone (reasons-without-context
39//!    is confusing to consumers, and the recovery path is
40//!    asymmetric since you'd have to negate reason-only labels
41//!    that were never paired with a takedown).
42//! 6. Otherwise → one [`LabelDraft`] per `reason_code`, with
43//!    `val = policy.reason_label_prefix + reason_code` and a fixed
44//!    `severity = Inform`. v1.5 has no per-reason severity config;
45//!    operators wanting different reason-label severities upgrade
46//!    once that surface lands (post-v1.5 if there's demand).
47//!
48//! # Expiry semantics
49//!
50//! For `TempSuspension`, the action label and any reason labels
51//! all carry `exp = action.expires_at` so consumer AppViews honor
52//! the same expiry on the whole bundle. Per #63: ATProto's native
53//! label expiry handles automatic negation in consumers — no
54//! cairn-mod scheduled job. For other action types, `exp = None`
55//! (takedown is permanent until revocation; indef_suspension
56//! likewise; warning has no inherent expiry).
57//!
58//! # Subject URI
59//!
60//! `LabelDraft.uri` is the AT-URI or DID the label is *about*. For
61//! account-level actions, the recorder passes
62//! `subject_uri = None` and the draft's `uri` becomes the
63//! `subject_did`. For record-level actions, the draft's `uri` is
64//! the action's `subject_uri`. The `cid` is always `None` here —
65//! the recorder fills it for record-level subjects from whatever
66//! source-of-truth it has at signing time.
67//!
68//! # `cts` from `now`, not from `action.effective_at`
69//!
70//! The label's `cts` (creation timestamp) is the moment the label
71//! is being emitted, not the action's `effective_at`. They're
72//! equal in practice (the recorder calls these functions with
73//! `now = effective_at` for immediate actions, which is every v1.4
74//! action), but the input separation lets a future scheduled-action
75//! pipeline emit labels at a different moment than the action's
76//! effective_at without a signature mismatch.
77
78use std::time::SystemTime;
79
80use crate::config::{BlursToml, LocaleToml, SeverityToml};
81use crate::moderation::types::ActionType;
82
83use super::policy::LabelEmissionPolicy;
84
85/// Inputs the emission core needs from the in-flight action.
86///
87/// Distinct from [`crate::moderation::types::ActionRecord`] — the
88/// latter is a deliberately-narrow projection for the strike /
89/// decay / window calculators (#49/#50/#51) and intentionally omits
90/// `subject_did`, `subject_uri`, and `reason_codes`. The emission
91/// core needs all three, so it consumes a purpose-specific input
92/// type built by the recorder from the freshly-inserted row.
93///
94/// Same naming convention as the existing
95/// `load_subject_actions_for_calc` projection helper in
96/// `writer.rs` — "for X" suffix flags purpose-specific
97/// projections vs. the canonical row shape.
98#[derive(Debug, Clone)]
99pub struct ActionForEmission {
100    /// Graduated-action category. Drives the action-label gate
101    /// (note never emits; warning gated on policy.warning_emits_label;
102    /// others emit per policy).
103    pub action_type: ActionType,
104    /// `Some` for `TempSuspension` (drives the `exp` field on the
105    /// emitted label so consumer AppViews honor the expiry without
106    /// a scheduled negation, per #63). `None` for other types.
107    pub expires_at: Option<SystemTime>,
108    /// Account DID the action attributes to. Used as the label's
109    /// `uri` when `subject_uri` is absent (account-level actions).
110    pub subject_did: String,
111    /// AT-URI for record-level actions. When `Some`, becomes the
112    /// label's `uri`; otherwise the label targets the account DID.
113    pub subject_uri: Option<String>,
114    /// Reason identifiers from the operator's
115    /// `[moderation_reasons]` vocabulary (#47). Drives the reason
116    /// labels emitted alongside the action label.
117    pub reason_codes: Vec<String>,
118    /// CID for record-level subjects. Caller-supplied — the
119    /// emission core does not resolve CIDs from the network. For
120    /// account-level subjects, this is `None`. For record-level
121    /// subjects (`subject_uri = Some(at://...)`), the recorder
122    /// (#60) populates this from whatever source-of-truth it has
123    /// at record time (typically the moderator's input). v1.5 also
124    /// allows `None` here for record-level subjects — the protocol
125    /// permits record-level labels without CID, just less specific.
126    pub cid: Option<String>,
127}
128
129/// Unsigned label record produced by the emission core. The
130/// recorder (#60) signs and persists; #62 revocation reuses this
131/// type for negation labels.
132///
133/// Carries both wire-level fields (`val`, `neg`, `uri`, `cid`,
134/// `cts`, `exp`) and operator-policy metadata (`severity`, `blurs`,
135/// `locales`). The metadata isn't part of the signed wire record
136/// itself — that's covered by the labeler's service-record
137/// `labelValueDefinitions` per §F1 — but it travels with the draft
138/// so the recorder's audit row can capture which severity was in
139/// effect at emission time.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct LabelDraft {
142    /// Label value (e.g. `!takedown`, `reason-hate-speech`).
143    pub val: String,
144    /// Severity hint per [`LabelEmissionPolicy`]. For v1.5 reason
145    /// labels this is always `Inform`; for action labels it comes
146    /// from the operator's [`crate::labels::policy::LabelSpec`]
147    /// (default `Alert` for action types).
148    pub severity: SeverityToml,
149    /// Optional blur hint. Most graduated-action labels do not
150    /// specify blurs since they represent account-level state, not
151    /// content-level visual treatment.
152    pub blurs: Option<BlursToml>,
153    /// Optional localized display strings. Empty for reason labels
154    /// in v1.5; populated for action labels iff the operator
155    /// declared them in [`crate::config::LabelSpecToml::locales`].
156    pub locales: Vec<LocaleToml>,
157    /// Subject the label is *about* — either an AT-URI (record
158    /// subject) or a DID (account subject).
159    pub uri: String,
160    /// Subject CID for record subjects. Always `None` here; the
161    /// recorder fills this from its own context at signing time.
162    pub cid: Option<String>,
163    /// `false` for emission, `true` for negation. v1.5's emission
164    /// core only produces `false` here; #62's revocation flow
165    /// constructs the `true` case directly.
166    pub neg: bool,
167    /// Wall-clock the draft was minted. The recorder formats this
168    /// as RFC-3339 Z when constructing the wire-level
169    /// [`crate::label::Label`] for signing.
170    pub cts: SystemTime,
171    /// Expiry wall-clock for `TempSuspension` action labels and
172    /// the reason labels that share their gate. `None` for other
173    /// action types.
174    pub exp: Option<SystemTime>,
175}
176
177/// Resolve the action label(s) for an in-flight action. Returns
178/// at most one entry in v1.5; the `Vec` shape is forward-compatible
179/// with future multi-label-per-action surfaces.
180pub fn resolve_action_labels(
181    action: &ActionForEmission,
182    policy: &LabelEmissionPolicy,
183    now: SystemTime,
184) -> Vec<LabelDraft> {
185    if !policy.enabled {
186        return Vec::new();
187    }
188
189    let Some(spec) = policy.resolve_action_label(action.action_type) else {
190        return Vec::new();
191    };
192
193    let uri = action
194        .subject_uri
195        .clone()
196        .unwrap_or_else(|| action.subject_did.clone());
197
198    let exp = exp_for(action);
199
200    vec![LabelDraft {
201        val: spec.val,
202        severity: spec.severity,
203        blurs: spec.blurs,
204        locales: spec.locales,
205        uri,
206        cid: None,
207        neg: false,
208        cts: now,
209        exp,
210    }]
211}
212
213/// Resolve the reason labels for an in-flight action. Returns one
214/// entry per `reason_code` when emission is enabled and the
215/// action's gate permits.
216pub fn resolve_reason_labels(
217    action: &ActionForEmission,
218    policy: &LabelEmissionPolicy,
219    now: SystemTime,
220) -> Vec<LabelDraft> {
221    if !policy.enabled || !policy.emit_reason_labels || action.reason_codes.is_empty() {
222        return Vec::new();
223    }
224    if matches!(action.action_type, ActionType::Note) {
225        return Vec::new();
226    }
227    if matches!(action.action_type, ActionType::Warning) && !policy.warning_emits_label {
228        // Reason labels share the warning's suppression gate —
229        // see module docs for the rationale.
230        return Vec::new();
231    }
232
233    let uri = action
234        .subject_uri
235        .clone()
236        .unwrap_or_else(|| action.subject_did.clone());
237    let exp = exp_for(action);
238
239    action
240        .reason_codes
241        .iter()
242        .map(|reason_code| LabelDraft {
243            val: policy.resolve_reason_label_value(reason_code),
244            // v1.5 fixes reason-label severity at Inform: reason
245            // labels describe *why* a moderation event occurred,
246            // not *what* effect it has, so they're advisory by
247            // design. Per-reason severity config is deferred to a
248            // future release if real demand surfaces.
249            severity: SeverityToml::Inform,
250            blurs: None,
251            locales: Vec::new(),
252            uri: uri.clone(),
253            cid: None,
254            neg: false,
255            cts: now,
256            exp,
257        })
258        .collect()
259}
260
261/// Compute the expiry stamp for an action's emitted labels.
262/// `Some(action.expires_at)` for `TempSuspension`; `None` otherwise.
263/// Note that v1.5 treats a `TempSuspension` with `expires_at = None`
264/// as a degenerate input that emits a label without expiry —
265/// validation that temp_suspension carries expires_at is the
266/// recorder's contract (#63), not the emission core's concern.
267fn exp_for(action: &ActionForEmission) -> Option<SystemTime> {
268    match action.action_type {
269        ActionType::TempSuspension => action.expires_at,
270        _ => None,
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::config::{LabelEmissionPolicyToml, LabelSpecToml};
278    use std::collections::BTreeMap;
279    use std::time::{Duration, UNIX_EPOCH};
280
281    // ---------- fixture builders ----------
282
283    const SUBJECT_DID: &str = "did:plc:subject0000000000000000";
284    const SUBJECT_URI: &str = "at://did:plc:subject0000000000000000/app.bsky.feed.post/aaa";
285
286    fn t0() -> SystemTime {
287        UNIX_EPOCH + Duration::from_secs(2_000_000_000)
288    }
289
290    fn account_action(action_type: ActionType, reasons: &[&str]) -> ActionForEmission {
291        ActionForEmission {
292            action_type,
293            expires_at: None,
294            subject_did: SUBJECT_DID.to_string(),
295            subject_uri: None,
296            reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
297            cid: None,
298        }
299    }
300
301    fn record_action(action_type: ActionType, reasons: &[&str]) -> ActionForEmission {
302        ActionForEmission {
303            action_type,
304            expires_at: None,
305            subject_did: SUBJECT_DID.to_string(),
306            subject_uri: Some(SUBJECT_URI.to_string()),
307            reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
308            cid: None,
309        }
310    }
311
312    fn temp_suspension_action(reasons: &[&str], expires_at: SystemTime) -> ActionForEmission {
313        ActionForEmission {
314            action_type: ActionType::TempSuspension,
315            expires_at: Some(expires_at),
316            subject_did: SUBJECT_DID.to_string(),
317            subject_uri: None,
318            reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
319            cid: None,
320        }
321    }
322
323    /// Construct a policy by merging a serde_json patch into the
324    /// defaults. Keeps tests focused on the diff from defaults
325    /// rather than re-typing every field.
326    fn policy(patch: serde_json::Value) -> LabelEmissionPolicy {
327        let mut base = serde_json::json!({
328            "service_did": "did:web:labeler.example",
329            "service_endpoint": "https://labeler.example",
330            "db_path": "/var/lib/cairn/cairn.db",
331            "signing_key_path": "/etc/cairn/signing-key.hex",
332        });
333        if !patch.is_null() {
334            base["label_emission"] = patch;
335        }
336        let cfg: crate::config::Config = serde_json::from_value(base).expect("config deserializes");
337        LabelEmissionPolicy::from_config(&cfg).expect("policy resolves")
338    }
339
340    fn defaults_policy() -> LabelEmissionPolicy {
341        LabelEmissionPolicy::defaults()
342    }
343
344    // Sanity: confirm the imported config types exist (otherwise
345    // the import would be flagged unused). Kept as a compile-time
346    // touchpoint because the brief's algorithm reads
347    // LabelSpecToml + LabelEmissionPolicyToml field names.
348    fn _toml_types_exist() -> (LabelSpecToml, LabelEmissionPolicyToml, BTreeMap<String, ()>) {
349        unreachable!()
350    }
351
352    // ============================================================
353    // resolve_action_labels
354    // ============================================================
355
356    #[test]
357    fn action_labels_disabled_policy_returns_empty() {
358        let p = policy(serde_json::json!({ "enabled": false }));
359        assert!(
360            resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0()).is_empty()
361        );
362    }
363
364    #[test]
365    fn action_labels_note_returns_empty() {
366        let p = defaults_policy();
367        assert!(
368            resolve_action_labels(&account_action(ActionType::Note, &["spam"]), &p, t0())
369                .is_empty()
370        );
371    }
372
373    #[test]
374    fn action_labels_warning_suppressed_by_default() {
375        let p = defaults_policy();
376        assert!(
377            resolve_action_labels(&account_action(ActionType::Warning, &["spam"]), &p, t0())
378                .is_empty()
379        );
380    }
381
382    #[test]
383    fn action_labels_warning_emits_when_flag_true() {
384        let p = policy(serde_json::json!({ "warning_emits_label": true }));
385        let out = resolve_action_labels(&account_action(ActionType::Warning, &["spam"]), &p, t0());
386        assert_eq!(out.len(), 1);
387        assert_eq!(out[0].val, "!warn");
388        assert!(matches!(out[0].severity, SeverityToml::Inform));
389        assert!(!out[0].neg);
390    }
391
392    #[test]
393    fn action_labels_takedown_emits_default() {
394        let p = defaults_policy();
395        let out = resolve_action_labels(
396            &account_action(ActionType::Takedown, &["hate-speech"]),
397            &p,
398            t0(),
399        );
400        assert_eq!(out.len(), 1);
401        assert_eq!(out[0].val, "!takedown");
402        assert!(matches!(out[0].severity, SeverityToml::Alert));
403        assert_eq!(out[0].exp, None);
404    }
405
406    #[test]
407    fn action_labels_indef_suspension_emits_hide_no_exp() {
408        let p = defaults_policy();
409        let out =
410            resolve_action_labels(&account_action(ActionType::IndefSuspension, &[]), &p, t0());
411        assert_eq!(out.len(), 1);
412        assert_eq!(out[0].val, "!hide");
413        assert_eq!(out[0].exp, None);
414    }
415
416    #[test]
417    fn action_labels_temp_suspension_with_expiry_propagates_to_exp() {
418        let p = defaults_policy();
419        let exp = t0() + Duration::from_secs(7 * 86_400);
420        let out = resolve_action_labels(&temp_suspension_action(&[], exp), &p, t0());
421        assert_eq!(out.len(), 1);
422        assert_eq!(out[0].val, "!hide");
423        assert_eq!(out[0].exp, Some(exp));
424    }
425
426    #[test]
427    fn action_labels_temp_suspension_without_expiry_emits_with_none_exp() {
428        // Degenerate but legal at this layer — the recorder is
429        // responsible for ensuring temp_suspension always carries
430        // expires_at (#63 contract). #59 just translates whatever
431        // it gets.
432        let p = defaults_policy();
433        let action = ActionForEmission {
434            action_type: ActionType::TempSuspension,
435            expires_at: None,
436            subject_did: SUBJECT_DID.to_string(),
437            subject_uri: None,
438            reason_codes: vec![],
439            cid: None,
440        };
441        let out = resolve_action_labels(&action, &p, t0());
442        assert_eq!(out.len(), 1);
443        assert_eq!(out[0].exp, None);
444    }
445
446    #[test]
447    fn action_labels_operator_override_replaces_val() {
448        let p = policy(serde_json::json!({
449            "action_label_overrides": {
450                "takedown": { "val": "!hideaway-takedown", "severity": "alert" }
451            }
452        }));
453        let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
454        assert_eq!(out[0].val, "!hideaway-takedown");
455    }
456
457    #[test]
458    fn action_labels_severity_override_applies_to_default_val() {
459        let p = policy(serde_json::json!({
460            "warning_emits_label": true,
461            "severity_overrides": { "warning": "alert" }
462        }));
463        let out = resolve_action_labels(&account_action(ActionType::Warning, &[]), &p, t0());
464        assert_eq!(out[0].val, "!warn"); // default val
465        assert!(matches!(out[0].severity, SeverityToml::Alert)); // overridden severity
466    }
467
468    #[test]
469    fn action_labels_account_subject_uses_did_as_uri() {
470        let p = defaults_policy();
471        let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
472        assert_eq!(out[0].uri, SUBJECT_DID);
473        assert_eq!(out[0].cid, None);
474    }
475
476    #[test]
477    fn action_labels_record_subject_uses_uri() {
478        let p = defaults_policy();
479        let out = resolve_action_labels(&record_action(ActionType::Takedown, &[]), &p, t0());
480        assert_eq!(out[0].uri, SUBJECT_URI);
481        assert_eq!(out[0].cid, None);
482    }
483
484    #[test]
485    fn action_labels_cts_takes_now_arg() {
486        let p = defaults_policy();
487        let now = t0() + Duration::from_secs(42);
488        let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, now);
489        assert_eq!(out[0].cts, now);
490    }
491
492    #[test]
493    fn action_labels_blurs_and_locales_propagate_from_override() {
494        let p = policy(serde_json::json!({
495            "action_label_overrides": {
496                "takedown": {
497                    "val": "!hideaway-takedown",
498                    "severity": "alert",
499                    "blurs": "media",
500                    "locales": [
501                        { "lang": "en", "name": "Removed", "description": "Account removed by moderation" }
502                    ]
503                }
504            }
505        }));
506        let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
507        assert!(matches!(out[0].blurs, Some(BlursToml::Media)));
508        assert_eq!(out[0].locales.len(), 1);
509        assert_eq!(out[0].locales[0].lang, "en");
510    }
511
512    // ============================================================
513    // resolve_reason_labels
514    // ============================================================
515
516    #[test]
517    fn reason_labels_disabled_policy_returns_empty() {
518        let p = policy(serde_json::json!({ "enabled": false }));
519        let action = account_action(ActionType::Takedown, &["spam", "hate-speech"]);
520        assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
521    }
522
523    #[test]
524    fn reason_labels_emit_reason_labels_false_returns_empty() {
525        let p = policy(serde_json::json!({ "emit_reason_labels": false }));
526        let action = account_action(ActionType::Takedown, &["spam"]);
527        assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
528    }
529
530    #[test]
531    fn reason_labels_empty_reason_codes_returns_empty() {
532        let p = defaults_policy();
533        let action = account_action(ActionType::Takedown, &[]);
534        assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
535    }
536
537    #[test]
538    fn reason_labels_note_returns_empty_even_with_reasons() {
539        let p = defaults_policy();
540        let action = account_action(ActionType::Note, &["spam", "hate-speech"]);
541        assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
542    }
543
544    #[test]
545    fn reason_labels_warning_suppressed_by_default() {
546        // Same gate as action labels: if the warning's action label
547        // is suppressed, reason labels are too.
548        let p = defaults_policy();
549        let action = account_action(ActionType::Warning, &["spam"]);
550        assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
551    }
552
553    #[test]
554    fn reason_labels_warning_emits_when_warning_emits_label_true() {
555        let p = policy(serde_json::json!({ "warning_emits_label": true }));
556        let action = account_action(ActionType::Warning, &["spam"]);
557        let out = resolve_reason_labels(&action, &p, t0());
558        assert_eq!(out.len(), 1);
559        assert_eq!(out[0].val, "reason-spam");
560    }
561
562    #[test]
563    fn reason_labels_takedown_with_three_reasons_emits_three() {
564        let p = defaults_policy();
565        let action = account_action(ActionType::Takedown, &["spam", "hate-speech", "harassment"]);
566        let out = resolve_reason_labels(&action, &p, t0());
567        assert_eq!(out.len(), 3);
568        let vals: Vec<&str> = out.iter().map(|d| d.val.as_str()).collect();
569        assert_eq!(
570            vals,
571            vec!["reason-spam", "reason-hate-speech", "reason-harassment"]
572        );
573        for d in &out {
574            assert!(matches!(d.severity, SeverityToml::Inform));
575            assert!(!d.neg);
576            assert_eq!(d.cts, t0());
577            assert_eq!(d.uri, SUBJECT_DID);
578        }
579    }
580
581    #[test]
582    fn reason_labels_temp_suspension_inherits_action_exp() {
583        let p = defaults_policy();
584        let exp = t0() + Duration::from_secs(7 * 86_400);
585        let action = temp_suspension_action(&["spam", "nsfw"], exp);
586        let out = resolve_reason_labels(&action, &p, t0());
587        assert_eq!(out.len(), 2);
588        for d in &out {
589            assert_eq!(d.exp, Some(exp));
590        }
591    }
592
593    #[test]
594    fn reason_labels_use_custom_prefix() {
595        let p = policy(serde_json::json!({ "reason_label_prefix": "rsn-" }));
596        let action = account_action(ActionType::Takedown, &["spam"]);
597        let out = resolve_reason_labels(&action, &p, t0());
598        assert_eq!(out[0].val, "rsn-spam");
599    }
600
601    #[test]
602    fn reason_labels_use_empty_prefix_yields_bare_reason_codes() {
603        let p = policy(serde_json::json!({ "reason_label_prefix": "" }));
604        let action = account_action(ActionType::Takedown, &["spam"]);
605        let out = resolve_reason_labels(&action, &p, t0());
606        assert_eq!(out[0].val, "spam");
607    }
608
609    #[test]
610    fn reason_labels_record_subject_uses_uri() {
611        let p = defaults_policy();
612        let action = record_action(ActionType::Takedown, &["spam"]);
613        let out = resolve_reason_labels(&action, &p, t0());
614        assert_eq!(out[0].uri, SUBJECT_URI);
615    }
616
617    #[test]
618    fn reason_labels_takedown_has_no_exp() {
619        let p = defaults_policy();
620        let action = account_action(ActionType::Takedown, &["spam"]);
621        let out = resolve_reason_labels(&action, &p, t0());
622        assert_eq!(out[0].exp, None);
623    }
624
625    // ============================================================
626    // determinism
627    // ============================================================
628
629    #[test]
630    fn outputs_deterministic_for_same_inputs() {
631        let p = defaults_policy();
632        let action = account_action(ActionType::Takedown, &["spam", "hate-speech"]);
633        let now = t0();
634        let a = resolve_action_labels(&action, &p, now);
635        let b = resolve_action_labels(&action, &p, now);
636        assert_eq!(a, b);
637        let r1 = resolve_reason_labels(&action, &p, now);
638        let r2 = resolve_reason_labels(&action, &p, now);
639        assert_eq!(r1, r2);
640    }
641}