Skip to main content

furnace/nerve/
signal.rs

1//! What moves through the fabric: one [`Signal`] type for text, bytes, tensors and commands.
2
3use std::fmt;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant};
7
8use bytes::Bytes;
9use serde::{Deserialize, Serialize};
10use tokio::sync::oneshot;
11
12use crate::tensor::HostTensor;
13
14/// A neuron's index in its [`Fabric`](super::Fabric)
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16pub struct NeuronId(pub(crate) u32);
17
18impl NeuronId {
19    /// A signal that no neuron has stamped yet
20    pub(crate) const NONE: Self = Self(u32::MAX);
21
22    /// The index as a `usize`, for slices of neurons
23    pub fn index(self) -> usize {
24        self.0 as usize
25    }
26}
27
28impl fmt::Display for NeuronId {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        write!(f, "n{}", self.0)
31    }
32}
33
34/// A pathway's index in its fabric
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub(crate) struct PathwayId(pub(crate) u32);
37
38impl PathwayId {
39    /// The index as a `usize`, for slices of pathways
40    pub(crate) fn index(self) -> usize {
41        self.0 as usize
42    }
43}
44
45/// Unique per process, in order of creation
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
47pub struct SignalId(pub u64);
48
49static NEXT_SIGNAL: AtomicU64 = AtomicU64::new(1);
50
51impl SignalId {
52    fn next() -> Self {
53        Self(NEXT_SIGNAL.fetch_add(1, Ordering::Relaxed))
54    }
55}
56
57/// What a receptor extracted from its input: small, fixed-size, cheap to compare. Habituation
58/// tables and thresholds look at this and never at the payload.
59///
60/// The `sketch` is a 64-bit summary (a hash of the bytes for exact repeats, an embedding
61/// sketch for near repeats, a quantized rate for a stream); `kind` tells sketches of different
62/// origins apart, so the same number from a text line and from an image frame are not one
63/// feature.
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
65pub struct Feature {
66    /// Tells sketches of different origins apart (1 for [`Feature::sketch`])
67    pub kind: u16,
68    /// The 64-bit summary itself
69    pub sketch: u64,
70}
71
72impl Feature {
73    /// No feature: never habituates, since every such signal looks the same and a table entry
74    /// for it would attenuate everything
75    pub const NONE: Self = Self { kind: 0, sketch: 0 };
76
77    /// A feature from a kind and a sketch of your own
78    pub const fn new(kind: u16, sketch: u64) -> Self {
79        Self { kind, sketch }
80    }
81
82    /// A 64-bit sketch of `bytes` (kind 1): equal inputs give equal features. About 1 ns per
83    /// 8 bytes.
84    pub fn sketch(bytes: &[u8]) -> Self {
85        Self::new(1, sketch64(bytes))
86    }
87
88    /// [`Feature::sketch`] with a `kind` of your own
89    pub fn sketch_as(kind: u16, bytes: &[u8]) -> Self {
90        Self::new(kind, sketch64(bytes))
91    }
92
93    /// The same sketch under another `kind`
94    pub fn with_kind(self, kind: u16) -> Self {
95        Self { kind, ..self }
96    }
97
98    /// Whether this is [`Feature::NONE`]
99    pub fn is_none(self) -> bool {
100        self == Self::NONE
101    }
102}
103
104/// A fast, non-cryptographic 64-bit hash (multiply-xorshift over 8-byte words). The sketch
105/// has to be cheap: it is computed once per input at the receptor and then compared in
106/// nanoseconds by every habituation table on the way.
107pub(crate) fn sketch64(bytes: &[u8]) -> u64 {
108    const K: u64 = 0x9E37_79B9_7F4A_7C15;
109    let mut h = (bytes.len() as u64).wrapping_mul(K) ^ 0x2545_F491_4F6C_DD1D;
110    let (chunks, rest) = bytes.as_chunks::<8>();
111    for chunk in chunks {
112        h = (h ^ u64::from_le_bytes(*chunk)).wrapping_mul(K);
113        h ^= h >> 29;
114    }
115    if !rest.is_empty() {
116        let mut word = [0u8; 8];
117        word[..rest.len()].copy_from_slice(rest);
118        h = (h ^ u64::from_le_bytes(word)).wrapping_mul(K);
119        h ^= h >> 29;
120    }
121    h = (h ^ (h >> 32)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
122    h ^ (h >> 31)
123}
124
125/// How fast a signal is "approaching": the time by which an action is useless, and the rate
126/// of change the receptor saw. Pathway selection reads this and nothing else.
127#[derive(Clone, Copy, Debug, Default, PartialEq)]
128pub struct Urgency {
129    /// After this, no action is worth taking; `None` is "whenever"
130    pub deadline: Option<Instant>,
131    /// Signals per second at the receptor, or whatever rate of change it measures; 0 when
132    /// unknown. Informational for pathways; receptors may derive the deadline from it.
133    pub rate: f32,
134}
135
136impl Urgency {
137    /// No deadline and no known rate
138    pub const NONE: Self = Self {
139        deadline: None,
140        rate: 0.0,
141    };
142
143    /// An action is wanted within `d` from now
144    pub fn within(d: Duration) -> Self {
145        Self {
146            deadline: Some(Instant::now() + d),
147            rate: 0.0,
148        }
149    }
150
151    /// An action is wanted by `deadline`
152    pub fn at(deadline: Instant) -> Self {
153        Self {
154            deadline: Some(deadline),
155            rate: 0.0,
156        }
157    }
158
159    /// The same urgency with this rate
160    pub fn with_rate(self, rate: f32) -> Self {
161        Self { rate, ..self }
162    }
163
164    /// Time left before the deadline; `None` without one, zero once it has passed
165    pub fn remaining(&self, now: Instant) -> Option<Duration> {
166        self.deadline.map(|d| d.saturating_duration_since(now))
167    }
168}
169
170/// An effector and its arguments: what a brain or a reflex tells the motor side to do
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct Command {
173    /// The name an effector was registered under
174    pub effector: String,
175    /// Whatever that effector takes: a JSON body for a webhook, `args` for a process, a line
176    /// for a journal
177    #[serde(default)]
178    pub args: serde_json::Value,
179}
180
181impl Command {
182    /// A command for the effector registered as `effector`
183    pub fn new(effector: &str, args: serde_json::Value) -> Self {
184        Self {
185            effector: effector.to_string(),
186            args,
187        }
188    }
189}
190
191/// The raw input, held by shared handle and never copied between neurons. A reflex pathway
192/// usually never looks at it.
193#[derive(Clone, Debug, Default)]
194pub enum Payload {
195    /// Nothing: the signal is its feature alone
196    #[default]
197    None,
198    /// Raw bytes
199    Bytes(Bytes),
200    /// UTF-8 text
201    Text(Arc<str>),
202    /// A JSON value
203    Json(Arc<serde_json::Value>),
204    /// Tensors, for a model
205    Tensors(Arc<Vec<HostTensor>>),
206    /// A command for an effector
207    Command(Arc<Command>),
208    /// A request for an LLM to generate (feature `llm`): what the OpenAI routes send the
209    /// brain
210    #[cfg(feature = "llm")]
211    Prompt(Arc<crate::llm::GenerateRequest>),
212}
213
214impl Payload {
215    /// A text payload
216    pub fn text(s: &str) -> Self {
217        Self::Text(Arc::from(s))
218    }
219
220    /// A bytes payload
221    pub fn bytes(b: impl Into<Bytes>) -> Self {
222        Self::Bytes(b.into())
223    }
224
225    /// A JSON payload
226    pub fn json(v: serde_json::Value) -> Self {
227        Self::Json(Arc::new(v))
228    }
229
230    /// A command payload
231    pub fn command(c: Command) -> Self {
232        Self::Command(Arc::new(c))
233    }
234
235    /// A tensors payload
236    pub fn tensors(t: Vec<HostTensor>) -> Self {
237        Self::Tensors(Arc::new(t))
238    }
239
240    /// The text, for `Text` and for `Bytes` that are UTF-8
241    pub fn as_text(&self) -> Option<&str> {
242        match self {
243            Self::Text(s) => Some(s),
244            Self::Bytes(b) => std::str::from_utf8(b).ok(),
245            _ => None,
246        }
247    }
248
249    /// The bytes, for `Bytes` and for `Text` as UTF-8
250    pub fn as_bytes(&self) -> Option<&[u8]> {
251        match self {
252            Self::Bytes(b) => Some(b),
253            Self::Text(s) => Some(s.as_bytes()),
254            _ => None,
255        }
256    }
257
258    /// The command, for `Command`
259    pub fn as_command(&self) -> Option<&Command> {
260        match self {
261            Self::Command(c) => Some(c),
262            _ => None,
263        }
264    }
265
266    /// Whether this payload is a command for `effector`
267    pub fn commands(&self, effector: &str) -> bool {
268        self.as_command().is_some_and(|c| c.effector == effector)
269    }
270
271    /// The tensors, for `Tensors`
272    pub fn as_tensors(&self) -> Option<&[HostTensor]> {
273        match self {
274            Self::Tensors(t) => Some(t),
275            _ => None,
276        }
277    }
278
279    /// The JSON value, parsing `Text` and `Bytes` on demand; a `Command` as its JSON object
280    pub fn to_json(&self) -> Option<serde_json::Value> {
281        match self {
282            Self::Json(v) => Some((**v).clone()),
283            Self::Text(_) | Self::Bytes(_) => serde_json::from_slice(self.as_bytes()?).ok(),
284            Self::Command(c) => serde_json::to_value(&**c).ok(),
285            Self::None | Self::Tensors(_) => None,
286            #[cfg(feature = "llm")]
287            Self::Prompt(_) => None,
288        }
289    }
290
291    /// Bytes on the wire, for effectors that send the payload as it is: text and bytes as
292    /// they are, JSON and commands serialized, tensors as little-endian values
293    pub fn to_bytes(&self) -> Bytes {
294        match self {
295            Self::None => Bytes::new(),
296            Self::Bytes(b) => b.clone(),
297            Self::Text(s) => Bytes::copy_from_slice(s.as_bytes()),
298            Self::Json(v) => Bytes::from(v.to_string()),
299            Self::Command(c) => Bytes::from(serde_json::to_vec(&**c).unwrap_or_default()),
300            Self::Tensors(t) => Bytes::from(
301                t.iter()
302                    .flat_map(|t| match &t.values {
303                        crate::tensor::Values::F32(v) => {
304                            v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>()
305                        }
306                        crate::tensor::Values::I64(v) => {
307                            v.iter().flat_map(|x| x.to_le_bytes()).collect()
308                        }
309                    })
310                    .collect::<Vec<u8>>(),
311            ),
312            #[cfg(feature = "llm")]
313            Self::Prompt(_) => Bytes::new(),
314        }
315    }
316
317    /// The variant's name, lowercase (`"text"`, `"command"`, ...)
318    pub fn kind(&self) -> &'static str {
319        match self {
320            Self::None => "none",
321            Self::Bytes(_) => "bytes",
322            Self::Text(_) => "text",
323            Self::Json(_) => "json",
324            Self::Tensors(_) => "tensors",
325            Self::Command(_) => "command",
326            #[cfg(feature = "llm")]
327            Self::Prompt(_) => "prompt",
328        }
329    }
330}
331
332/// Most hops a trace records inline; longer chains count the rest
333pub(crate) const TRACE_HOPS: usize = 8;
334
335/// Where a signal has been: the neurons that fired it on, in order, without an allocation
336#[derive(Clone, Copy, Debug)]
337pub struct Trace {
338    hops: [NeuronId; TRACE_HOPS],
339    len: u8,
340    /// Hops beyond the first [`TRACE_HOPS`]
341    overflow: u16,
342    brain: bool,
343    /// Whether the signal went through a deliberative queue (then it is not a reflex arc)
344    queued: bool,
345}
346
347impl Default for Trace {
348    fn default() -> Self {
349        Self {
350            hops: [NeuronId::NONE; TRACE_HOPS],
351            len: 0,
352            overflow: 0,
353            brain: false,
354            queued: false,
355        }
356    }
357}
358
359impl Trace {
360    pub(crate) fn push(&mut self, id: NeuronId) {
361        if (self.len as usize) < TRACE_HOPS {
362            self.hops[self.len as usize] = id;
363            self.len += 1;
364        } else {
365            self.overflow = self.overflow.saturating_add(1);
366        }
367    }
368
369    pub(crate) fn mark_brain(&mut self) {
370        self.brain = true;
371    }
372
373    pub(crate) fn mark_queued(&mut self) {
374        self.queued = true;
375    }
376
377    /// The neurons passed so far (the first 8)
378    pub fn hops(&self) -> &[NeuronId] {
379        &self.hops[..self.len as usize]
380    }
381
382    /// Neurons passed in all
383    pub fn len(&self) -> usize {
384        self.len as usize + self.overflow as usize
385    }
386
387    /// Whether no neuron has been passed yet
388    pub fn is_empty(&self) -> bool {
389        self.len == 0 && self.overflow == 0
390    }
391
392    /// Whether a brain neuron saw this signal (or the one it was derived from)
393    pub fn reached_brain(&self) -> bool {
394        self.brain
395    }
396
397    /// Whether the signal waited in a deliberative queue
398    pub fn was_queued(&self) -> bool {
399        self.queued
400    }
401
402    /// The last neuron recorded in [`Trace::hops`]
403    pub fn last(&self) -> Option<NeuronId> {
404        self.hops().last().copied()
405    }
406}
407
408/// What a chain sends back to whoever started it, when that was a request
409#[derive(Debug)]
410pub enum Answer {
411    /// An HTTP response, as the [`Respond`](super::effector::Respond) effector makes it for
412    /// the HTTP receptor
413    Http {
414        /// HTTP status code
415        status: u16,
416        /// The `Content-Type` header
417        content_type: String,
418        /// The response body
419        body: Bytes,
420    },
421    /// Tensors, for a protocol that encodes them itself (the server's `/predict`, the
422    /// Open Inference Protocol over HTTP and gRPC)
423    Tensors(Vec<HostTensor>),
424    /// The chain failed and says why: the request's own error
425    Failed(Box<crate::error::FurnaceError>),
426    /// An LLM's generation (feature `llm`): its events, which go straight from the engine to
427    /// whoever asked, or why the engine refused it
428    #[cfg(feature = "llm")]
429    Generation(
430        std::result::Result<
431            tokio::sync::mpsc::Receiver<crate::llm::Event>,
432            crate::llm::EngineError,
433        >,
434    ),
435}
436
437impl Answer {
438    /// A `text/plain` response
439    pub fn text(status: u16, body: &str) -> Self {
440        Self::Http {
441            status,
442            content_type: "text/plain; charset=utf-8".to_string(),
443            body: Bytes::copy_from_slice(body.as_bytes()),
444        }
445    }
446
447    /// An `application/json` response
448    pub fn json(status: u16, value: &serde_json::Value) -> Self {
449        Self::Http {
450            status,
451            content_type: "application/json".to_string(),
452            body: Bytes::from(value.to_string()),
453        }
454    }
455
456    /// A response with a content type of your own
457    pub fn bytes(status: u16, content_type: &str, body: Bytes) -> Self {
458        Self::Http {
459            status,
460            content_type: content_type.to_string(),
461            body,
462        }
463    }
464
465    /// The chain failed with `error`
466    pub fn failed(error: crate::error::FurnaceError) -> Self {
467        Self::Failed(Box::new(error))
468    }
469}
470
471struct ReplyInner {
472    sender: Mutex<Option<oneshot::Sender<Answer>>>,
473    /// Whether the chain exists to answer: a signal whose requester stopped waiting is then
474    /// worth nothing, and a deliberative pathway skips it
475    required: bool,
476}
477
478/// The way back to the request that started a chain, carried by the signal and every signal
479/// derived from it. The first effector to answer wins; a chain that ends without answering
480/// drops the sender, and the receptor sees that.
481#[derive(Clone)]
482pub struct ReplyHandle(Arc<ReplyInner>);
483
484impl fmt::Debug for ReplyHandle {
485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486        f.debug_struct("ReplyHandle")
487            .field("open", &self.is_open())
488            .field("required", &self.0.required)
489            .finish()
490    }
491}
492
493impl ReplyHandle {
494    /// A way back that the chain may use: the chain still runs if nobody waits any more (a
495    /// request answered `202 Accepted` before its signal reached the brain)
496    pub fn new() -> (Self, oneshot::Receiver<Answer>) {
497        Self::make(false)
498    }
499
500    /// A way back the chain exists for: once nobody waits, the signal is dropped before it
501    /// reaches a deliberative pathway's neuron
502    pub fn required() -> (Self, oneshot::Receiver<Answer>) {
503        Self::make(true)
504    }
505
506    fn make(required: bool) -> (Self, oneshot::Receiver<Answer>) {
507        let (sender, receiver) = oneshot::channel();
508        let inner = ReplyInner {
509            sender: Mutex::new(Some(sender)),
510            required,
511        };
512        (Self(Arc::new(inner)), receiver)
513    }
514
515    /// Send the answer; `false` if one was sent already or nobody is waiting
516    pub fn answer(&self, answer: Answer) -> bool {
517        let sender = self
518            .0
519            .sender
520            .lock()
521            .unwrap_or_else(|e| e.into_inner())
522            .take();
523        sender.is_some_and(|s| s.send(answer).is_ok())
524    }
525
526    /// Whether the request still waits and no answer was sent yet
527    pub fn is_open(&self) -> bool {
528        self.0
529            .sender
530            .lock()
531            .unwrap_or_else(|e| e.into_inner())
532            .as_ref()
533            .is_some_and(|s| !s.is_closed())
534    }
535
536    /// Whether the signal carrying this is still worth running: always, unless the chain
537    /// exists to answer and nobody waits any more
538    pub fn wanted(&self) -> bool {
539        !self.0.required || self.is_open()
540    }
541}
542
543/// One thing that moves: a feature, an urgency, an intensity and a lazily shared payload.
544///
545/// Receptors make signals ([`Signal::new`]); neurons that fire make derived ones
546/// ([`Signal::derive`]) that keep the id, origin, timing and trace, so a command that leaves
547/// an effector can be traced back to the stimulus that caused it.
548#[derive(Clone, Debug)]
549pub struct Signal {
550    /// Shared by every signal derived from the same stimulus
551    pub id: SignalId,
552    /// The receptor that made it
553    pub origin: NeuronId,
554    /// When the receptor saw the stimulus: stimulus-to-action latencies count from here
555    pub at: Instant,
556    /// When an action is due; pathway selection reads it
557    pub urgency: Urgency,
558    /// What the receptor extracted; habituation and thresholds look at it
559    pub feature: Feature,
560    /// Strength after attenuation, 0..=1. Below a neuron's threshold, nothing fires.
561    pub intensity: f32,
562    /// The raw input, shared and never copied
563    pub payload: Payload,
564    /// The neurons it has passed through
565    pub trace: Trace,
566    /// The way back to the request that started the chain, if any
567    pub reply: Option<ReplyHandle>,
568}
569
570impl Signal {
571    /// A signal of full intensity and no urgency, seen now
572    pub fn new(feature: Feature, payload: Payload) -> Self {
573        Self {
574            id: SignalId::next(),
575            origin: NeuronId::NONE,
576            at: Instant::now(),
577            urgency: Urgency::NONE,
578            feature,
579            intensity: 1.0,
580            payload,
581            trace: Trace::default(),
582            reply: None,
583        }
584    }
585
586    /// The same signal with this urgency
587    pub fn with_urgency(mut self, urgency: Urgency) -> Self {
588        self.urgency = urgency;
589        self
590    }
591
592    /// The same signal with this intensity, 0..=1
593    pub fn with_intensity(mut self, intensity: f32) -> Self {
594        self.intensity = intensity;
595        self
596    }
597
598    /// The same signal with this feature
599    pub fn with_feature(mut self, feature: Feature) -> Self {
600        self.feature = feature;
601        self
602    }
603
604    /// The same signal carrying a way back to the request
605    pub fn with_reply(mut self, reply: ReplyHandle) -> Self {
606        self.reply = Some(reply);
607        self
608    }
609
610    /// A signal this one causes: same id, origin, timing, urgency, trace and reply, with a
611    /// new payload. The feature and intensity carry over; change them with the builders.
612    pub fn derive(&self, payload: Payload) -> Self {
613        Self {
614            id: self.id,
615            origin: self.origin,
616            at: self.at,
617            urgency: self.urgency,
618            feature: self.feature,
619            intensity: self.intensity,
620            payload,
621            trace: self.trace,
622            reply: self.reply.clone(),
623        }
624    }
625
626    /// [`Signal::derive`] with a [`Command`] payload: tell the effector named in it what to do
627    pub fn command(&self, effector: &str, args: serde_json::Value) -> Self {
628        self.derive(Payload::command(Command::new(effector, args)))
629    }
630
631    /// Time since the receptor saw the stimulus
632    pub fn age(&self) -> Duration {
633        self.at.elapsed()
634    }
635
636    /// Answer the request that started the chain, if there is one waiting
637    pub fn answer(&self, answer: Answer) -> bool {
638        self.reply.as_ref().is_some_and(|r| r.answer(answer))
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn sketches_tell_inputs_apart_and_repeat_for_equal_ones() {
648        assert_eq!(Feature::sketch(b"hello"), Feature::sketch(b"hello"));
649        assert_ne!(Feature::sketch(b"hello"), Feature::sketch(b"hellp"));
650        assert_ne!(Feature::sketch(b""), Feature::sketch(b"\0"));
651        assert_ne!(Feature::sketch(b"12345678"), Feature::sketch(b"123456789"));
652        assert_ne!(Feature::sketch(b"a"), Feature::sketch_as(2, b"a"));
653        assert!(!Feature::sketch(b"").is_none());
654    }
655
656    #[test]
657    fn trace_records_eight_hops_inline_and_counts_the_rest() {
658        let mut trace = Trace::default();
659        for i in 0..12 {
660            trace.push(NeuronId(i));
661        }
662        assert_eq!(trace.hops().len(), TRACE_HOPS);
663        assert_eq!(trace.len(), 12);
664        assert_eq!(trace.last(), Some(NeuronId(7)));
665        assert!(!trace.reached_brain());
666    }
667
668    #[test]
669    fn derived_signals_share_identity_and_reply() {
670        let (reply, mut receiver) = ReplyHandle::new();
671        let signal = Signal::new(Feature::sketch(b"x"), Payload::text("x"))
672            .with_reply(reply)
673            .with_urgency(Urgency::within(Duration::from_secs(1)));
674        let child = signal.command("log", serde_json::json!({"line": "x"}));
675        assert_eq!(child.id, signal.id);
676        assert_eq!(child.payload.as_command().unwrap().effector, "log");
677        assert!(child.urgency.remaining(Instant::now()).is_some());
678        assert!(child.answer(Answer::text(200, "ok")));
679        // Only the first answer counts
680        assert!(!signal.answer(Answer::text(200, "again")));
681        let Answer::Http { body, .. } = receiver.try_recv().unwrap() else {
682            panic!("an HTTP answer");
683        };
684        assert_eq!(body, Bytes::from_static(b"ok"));
685    }
686
687    #[test]
688    fn only_a_required_reply_makes_its_signal_unwanted_once_abandoned() {
689        let (optional, receiver) = ReplyHandle::new();
690        drop(receiver);
691        assert!(!optional.is_open());
692        assert!(optional.wanted());
693        let (required, receiver) = ReplyHandle::required();
694        assert!(required.wanted());
695        drop(receiver);
696        assert!(!required.wanted());
697    }
698
699    #[test]
700    fn payload_conversions() {
701        let json = Payload::text(r#"{"a":1}"#).to_json().unwrap();
702        assert_eq!(json["a"], 1);
703        assert_eq!(
704            Payload::json(json.clone()).to_bytes(),
705            Bytes::from(r#"{"a":1}"#)
706        );
707        let command = Payload::command(Command::new("w", serde_json::json!({"k": "v"})));
708        assert_eq!(command.to_json().unwrap()["effector"], "w");
709        assert!(Payload::None.as_text().is_none());
710        assert_eq!(Payload::bytes(&b"abc"[..]).as_text(), Some("abc"));
711    }
712}