eureka-pipeline: the Pulse kind (a Mind's heartbeat vote and declared urgency), instance-rooted; huginn-mind admits it in her own mind only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
21fb9b2b04
commit
a4f4d04665
7 changed files with 65 additions and 15 deletions
|
|
@ -112,7 +112,7 @@ macro_rules! unbounded_scalars {
|
|||
)*};
|
||||
}
|
||||
|
||||
unbounded_scalars!(u32, u64, bool);
|
||||
unbounded_scalars!(u16, u32, u64, bool);
|
||||
|
||||
fn bound(field: &str, limit: usize, actual: usize) -> Result<(), PipelineRefusal> {
|
||||
if actual > limit {
|
||||
|
|
@ -572,6 +572,31 @@ fn run_is_well_formed(run: &PipelineRun, at: &str) -> Result<(), PipelineRefusal
|
|||
}
|
||||
}
|
||||
|
||||
/// The longest and shortest beat a Mind may vote for, in seconds.
|
||||
pub const TEMPO_VOTE_MIN_S: u32 = 60;
|
||||
pub const TEMPO_VOTE_MAX_S: u32 = 86_400;
|
||||
/// Urgency is in thousandths: 0 declares none, 1000 the most.
|
||||
pub const URGENCY_MAX_PERMILLE: u16 = 1000;
|
||||
|
||||
/// A pulse votes a tempo or declares an urgency, in bounds, or says nothing it
|
||||
/// could be read for: an empty pulse is refused.
|
||||
fn pulse_is_well_formed(pulse: &PipelinePulse, at: &str) -> Result<(), PipelineRefusal> {
|
||||
if let Some(beat) = pulse.tempo_vote_beat_s
|
||||
&& !(TEMPO_VOTE_MIN_S..=TEMPO_VOTE_MAX_S).contains(&beat)
|
||||
{
|
||||
return Err(format_error(&format!("{at}.tempo_vote_beat_s"), &beat.to_string()));
|
||||
}
|
||||
if let Some(urgency) = pulse.urgency_permille
|
||||
&& urgency > URGENCY_MAX_PERMILLE
|
||||
{
|
||||
return Err(format_error(&format!("{at}.urgency_permille"), &urgency.to_string()));
|
||||
}
|
||||
if pulse.tempo_vote_beat_s.is_none() && pulse.urgency_permille.is_none() {
|
||||
return Err(format_error(&format!("{at}.tempo_vote_beat_s"), "a pulse that votes and declares nothing"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unit_enums! {
|
||||
EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture }
|
||||
ClaimOutcome { Holds, Falsified, Unproven }
|
||||
|
|
@ -710,6 +735,14 @@ value_types! {
|
|||
instance: Slug, label: Label, operated_by: RunOperator, turn: RunTurn, host: Short, started_on: Date,
|
||||
budget_usd: Short, claims: Vec<PipelineRef>[64], campaigns: Vec<Slug>[8],
|
||||
} => run_is_well_formed
|
||||
/// One Mind's heartbeat vote, in her own mind: the beat she wants (the swarm's
|
||||
/// tempo is the lower median of every Mind's newest vote) and the urgency she
|
||||
/// declares for herself until her next Persona turn. Either may be absent, not
|
||||
/// both. Instance-rooted: the key is `<instance>:pulse:<label>`, the label
|
||||
/// time-ordered like a run's.
|
||||
pub struct PipelinePulse {
|
||||
instance: Slug, label: Label, tempo_vote_beat_s: Option<u32>, urgency_permille: Option<u16>, note: Line,
|
||||
} => pulse_is_well_formed
|
||||
}
|
||||
|
||||
/// A reference to another document. The id is parsed as a full pipeline id of
|
||||
|
|
@ -812,7 +845,7 @@ macro_rules! pipeline_kinds {
|
|||
/// because a value is a named map of its own and an internal tag would
|
||||
/// have to be merged into it; the tag stays beside the value instead.
|
||||
/// This is not a published schema: `schemas/cultnet` publishes the
|
||||
/// fourteen per-kind value documents, and the envelope is the wire's
|
||||
/// fifteen per-kind value documents, and the envelope is the wire's
|
||||
/// shape, not a document's.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
|
|
@ -931,6 +964,8 @@ pipeline_kinds! {
|
|||
"epiphany.pipeline.hand_off.v2", "EpiphanyPipelineHandOffDocument";
|
||||
Run(PipelineRun) => EpiphanyPipelineRunDocument, "run",
|
||||
"epiphany.pipeline.run.v2", "EpiphanyPipelineRunDocument";
|
||||
Pulse(PipelinePulse) => EpiphanyPipelinePulseDocument, "pulse",
|
||||
"epiphany.pipeline.pulse.v2", "EpiphanyPipelinePulseDocument";
|
||||
}
|
||||
|
||||
/// Parses a full document id: the grammar read backwards. A key and an id are
|
||||
|
|
@ -1126,6 +1161,7 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefus
|
|||
}
|
||||
// A run hangs off its instance too: its label is its whole local.
|
||||
D::Run(value) => ("run.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||||
D::Pulse(value) => ("pulse.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||||
D::Target(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&format!("r{}", value.revision)])?),
|
||||
D::Question(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||||
D::Ruling(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||||
|
|
@ -1370,6 +1406,10 @@ mod tests {
|
|||
claims: vec![PipelineRef { kind: PipelineKind::CutSpec, id: id("cut_spec", "cut-run-kind.r2") }],
|
||||
campaigns: vec![slug(CAMPAIGN)],
|
||||
}), format!("{INSTANCE}:run:mind-2026-10-09-1")),
|
||||
(D::Pulse(PipelinePulse {
|
||||
instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: Some(600), urgency_permille: Some(250),
|
||||
note: "The swarm is idle; a shorter beat.".into(),
|
||||
}), format!("{INSTANCE}:pulse:2026-10-31t041200-t0")),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -3213,11 +3253,11 @@ mod tests {
|
|||
};
|
||||
let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?;
|
||||
let variants = document_schema["oneOf"].as_array().expect("the document schema is a oneOf");
|
||||
assert_eq!(PipelineKind::ALL.len(), 14, "fourteen kinds");
|
||||
assert_eq!(PipelineKind::ALL.len(), 15, "fifteen kinds");
|
||||
assert_eq!(
|
||||
variants.iter().map(tag).collect::<Vec<_>>(),
|
||||
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
|
||||
"the document schema lists exactly the fourteen kinds, by name"
|
||||
"the document schema lists exactly the fifteen kinds, by name"
|
||||
);
|
||||
|
||||
let refusal_schema = serde_json::to_value(schemars::schema_for!(PipelineRefusal))?;
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ fn refuse_foreign_instance(document: &PipelineDocument, mind: &Slug) -> Result<(
|
|||
PipelineDocument::Instance(value) if value.instance != *mind => Err(foreign(&value.instance)),
|
||||
PipelineDocument::Stewardship(value) if value.instance != *mind => Err(foreign(&value.instance)),
|
||||
PipelineDocument::Run(value) if value.instance != *mind => Err(foreign(&value.instance)),
|
||||
PipelineDocument::Pulse(value) if value.instance != *mind => Err(foreign(&value.instance)),
|
||||
PipelineDocument::HandOff(value) if value.from_instance != *mind && value.to_instance != *mind => {
|
||||
Err(foreign(&value.from_instance))
|
||||
}
|
||||
|
|
@ -620,7 +621,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
|
|||
let replay = docs.in_image(K::Run, key) == Some(&staged.document);
|
||||
run_rule(docs, key, run, replay)
|
||||
}
|
||||
D::FollowUp(_) | D::Instance(_) => Ok(()),
|
||||
D::FollowUp(_) | D::Instance(_) | D::Pulse(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -337,7 +337,8 @@ pub(crate) fn line_of(document: &PipelineDocument) -> Option<Lineage<'_>> {
|
|||
| D::Resolution(_)
|
||||
| D::Instance(_)
|
||||
| D::HandOff(_)
|
||||
| D::Run(_) => None,
|
||||
| D::Run(_)
|
||||
| D::Pulse(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -537,7 +538,7 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel
|
|||
hand_off.documents.iter().filter_map(|to| kind_of_id(&to.0).map(|kind| (R::Documents, cited(kind, to)))),
|
||||
),
|
||||
D::Run(run) => edges.extend(run.claims.iter().map(|to| (R::Claims, to.clone()))),
|
||||
D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) => {}
|
||||
D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::Pulse(_) => {}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
|
|||
}
|
||||
D::Resolution(resolution) => lines.push(&resolution.rationale.0),
|
||||
D::HandOff(hand_off) => lines.push(&hand_off.reason.0),
|
||||
D::Instance(_) | D::Stewardship(_) | D::Run(_) => {}
|
||||
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) => {}
|
||||
}
|
||||
lines.retain(|line| !line.is_empty());
|
||||
if lines.is_empty() {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefu
|
|||
}
|
||||
|
||||
/// A cache that knows the seventeen types a mind's store may hold: the leaf's
|
||||
/// fourteen through its registrar, the epoch record, the commit receipt and
|
||||
/// fifteen through its registrar, the epoch record, the commit receipt and
|
||||
/// the persona entry. It is the cache every envelope is prepared against.
|
||||
pub(crate) fn schema_cache() -> Result<CultCache, MindRefusal> {
|
||||
let mut cache = CultCache::new();
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ pub enum PipelineFacts {
|
|||
Stewardship { instance: Slug, repo: OrgRepo, sequence: u32, assigned_on: Date, note: Line },
|
||||
HandOff { from_instance: Slug, to_instance: Slug, repo: OrgRepo, handed_on: Date },
|
||||
Run { label: Label, operated_by: RunOperator, turn: RunTurn, started_on: Date, claims: Vec<PipelineRef> },
|
||||
Pulse { label: Label, tempo_vote_beat_s: Option<u32>, urgency_permille: Option<u16> },
|
||||
}
|
||||
|
||||
impl PipelineFacts {
|
||||
|
|
@ -239,6 +240,11 @@ impl PipelineFacts {
|
|||
started_on: value.started_on.clone(),
|
||||
claims: value.claims.clone(),
|
||||
},
|
||||
D::Pulse(value) => Self::Pulse {
|
||||
label: value.label.clone(),
|
||||
tempo_vote_beat_s: value.tempo_vote_beat_s,
|
||||
urgency_permille: value.urgency_permille,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ impl SelectionRow {
|
|||
D::FollowUp(follow_up) => vec![follow_up.repo.identity()],
|
||||
D::Stewardship(stewardship) => vec![stewardship.repo.identity()],
|
||||
D::HandOff(hand_off) => vec![hand_off.repo.identity()],
|
||||
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) => {
|
||||
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) => {
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
|
|
@ -255,7 +255,7 @@ impl Row for SelectionRow {
|
|||
}
|
||||
}
|
||||
|
||||
/// The organ's closed lists: fourteen schemas, eleven aliases, sixteen roles.
|
||||
/// The organ's closed lists: fifteen schemas, eleven aliases, sixteen roles.
|
||||
/// A schema's name is its id, so the substrate's alias matching resolves
|
||||
/// nothing beyond exact ids.
|
||||
pub(crate) struct Vocabulary;
|
||||
|
|
@ -301,7 +301,7 @@ fn invalid(field: impl Into<String>, value: &str, message: impl Into<String>) ->
|
|||
/// because `OrgRepo` identity is case-insensitive.
|
||||
///
|
||||
/// Refuses, naming the field the client sent: a `schemas` entry that is not
|
||||
/// one of the fourteen ids; a `keys` entry that is not a full id whose kind
|
||||
/// one of the fifteen ids; a `keys` entry that is not a full id whose kind
|
||||
/// segment reads and passes the leaf's ref grammar; an `any_of` value outside
|
||||
/// its alias's domain; and a `cites.target` whose key fails the ref grammar
|
||||
/// for its schema's kind (V24's rule, at this door). Whether the target
|
||||
|
|
@ -368,7 +368,7 @@ mod tests {
|
|||
/// refused at the substrate's door.
|
||||
#[test]
|
||||
fn each_kind_declares_exactly_its_aliases_and_none_is_numeric() {
|
||||
let table: [(K, &[&str]); 14] = [
|
||||
let table: [(K, &[&str]); 15] = [
|
||||
(K::Campaign, &["repo"]),
|
||||
(K::Target, &[]),
|
||||
(K::Question, &[]),
|
||||
|
|
@ -383,6 +383,7 @@ mod tests {
|
|||
(K::Stewardship, &["repo"]),
|
||||
(K::HandOff, &["repo"]),
|
||||
(K::Run, &["turn", "operated_by"]),
|
||||
(K::Pulse, &[]),
|
||||
];
|
||||
for (kind, extra) in table {
|
||||
let want = [&["root", "in_force", "faculty"][..], extra].concat();
|
||||
|
|
@ -395,10 +396,10 @@ mod tests {
|
|||
|
||||
/// Each role is declared by the one kind whose field carries it, and its
|
||||
/// targets are the kinds that field is typed to, "any" spelled as all
|
||||
/// fourteen.
|
||||
/// fifteen.
|
||||
#[test]
|
||||
fn each_kind_declares_the_roles_it_carries_and_their_targets() {
|
||||
let table: [(K, &[&str]); 14] = [
|
||||
let table: [(K, &[&str]); 15] = [
|
||||
(K::Campaign, &[]),
|
||||
(K::Target, &[]),
|
||||
(K::Question, &["raised_in"]),
|
||||
|
|
@ -413,6 +414,7 @@ mod tests {
|
|||
(K::Stewardship, &[]),
|
||||
(K::HandOff, &["documents"]),
|
||||
(K::Run, &["claims"]),
|
||||
(K::Pulse, &[]),
|
||||
];
|
||||
for (kind, roles) in table {
|
||||
assert_eq!(sorted(Vocabulary.declared_roles(kind.type_id())), strings(roles), "{kind:?}");
|
||||
|
|
|
|||
Loading…
Reference in a new issue