///|
/// Object Contract package for Isochronon W-1.
///|
pub fn package_id() -> String {
"isocontract"
}
///|
pub(all) enum ObjectKind {
Signal
Command
Query
Event
Lifecycle
} derive(Eq, Debug)
///|
pub fn ObjectKind::label(self : ObjectKind) -> String {
match self {
Signal => "Signal"
Command => "Command"
Query => "Query"
Event => "Event"
Lifecycle => "Lifecycle"
}
}
///|
pub(all) enum Schema {
Bool
I8
I32
U16
State
Text
} derive(Eq, Debug)
///|
pub fn Schema::label(self : Schema) -> String {
match self {
Bool => "bool"
I8 => "i8"
I32 => "i32"
U16 => "u16"
State => "state"
Text => "text"
}
}
///|
pub(all) enum Authority {
Any
ReadOnly
Role(String)
} derive(Eq, Debug)
///|
pub fn Authority::label(self : Authority) -> String {
match self {
Any => "any"
ReadOnly => "read_only"
Role(name) => name
}
}
///|
pub fn Authority::allows(self : Authority, node_id : String) -> Bool {
match self {
Any => true
ReadOnly => false
Role(name) => node_id == name
}
}
///|
pub(all) enum ObjectClockRequirement {
AnyClock
PhysicalClock
} derive(Eq, Debug)
///|
pub fn ObjectClockRequirement::label(self : ObjectClockRequirement) -> String {
match self {
AnyClock => "any"
PhysicalClock => "physical"
}
}
///|
pub(all) struct TimingContract {
period_ns : Int64?
max_age_ns : Int64?
deadline_ns : Int64?
valid_for_ns : Int64?
clock_requirement : ObjectClockRequirement
} derive(Eq, Debug)
///|
pub fn TimingContract::make(
period_ns? : Int64,
max_age_ns? : Int64,
deadline_ns? : Int64,
valid_for_ns? : Int64,
clock_requirement? : ObjectClockRequirement = AnyClock,
) -> TimingContract {
{ period_ns, max_age_ns, deadline_ns, valid_for_ns, clock_requirement }
}
///|
pub fn TimingContract::empty() -> TimingContract {
TimingContract::make()
}
///|
pub(all) struct SafetyContract {
authority : Authority
min_payload_digest : Int?
max_payload_digest : Int?
timeout_action : String
} derive(Eq, Debug)
///|
pub fn SafetyContract::make(
authority? : Authority = Any,
min_payload_digest? : Int,
max_payload_digest? : Int,
timeout_action? : String = "hold_last",
) -> SafetyContract {
{ authority, min_payload_digest, max_payload_digest, timeout_action }
}
///|
pub(all) enum BindingKind {
TraceLabel
EtherCatPdo
CoeSdo
CanopenSdo
CanopenPdo
ZenohKey
ModbusRegister
} derive(Eq, Debug)
///|
pub fn BindingKind::label(self : BindingKind) -> String {
match self {
TraceLabel => "trace_label"
EtherCatPdo => "ethercat_pdo"
CoeSdo => "coe_sdo"
CanopenSdo => "canopen_sdo"
CanopenPdo => "canopen_pdo"
ZenohKey => "zenoh_key"
ModbusRegister => "modbus_register"
}
}
///|
pub(all) struct ObjectBinding {
object_id : String
binding_id : String
medium_id : String
label : String
direction : @trace.TraceDirection?
kind : BindingKind
index : UInt?
subindex : Byte?
channel : String
key : String
authority : Authority
} derive(Eq, Debug)
///|
pub fn ObjectBinding::make(
object_id~ : String,
binding_id~ : String,
medium_id? : String = "",
label~ : String,
direction? : @trace.TraceDirection,
kind? : BindingKind = TraceLabel,
index? : UInt,
subindex? : Byte,
channel? : String = "",
key? : String = "",
authority? : Authority = Any,
) -> ObjectBinding {
{
object_id,
binding_id,
medium_id,
label,
direction,
kind,
index,
subindex,
channel,
key,
authority,
}
}
///|
pub fn ObjectBinding::matches(
self : ObjectBinding,
event : @trace.TraceEvent,
) -> Bool {
let medium_ok = self.medium_id == "" || self.medium_id == event.medium_id
let direction_ok = match self.direction {
Some(direction) => direction == event.direction
None => true
}
self.label == event.label && medium_ok && direction_ok
}
///|
pub(all) struct ObjectContract {
id : String
kind : ObjectKind
schema : Schema
timing : TimingContract
safety : SafetyContract
bindings : Array[ObjectBinding]
} derive(Eq, Debug)
///|
pub fn ObjectContract::make(
id~ : String,
kind~ : ObjectKind,
schema~ : Schema,
timing~ : TimingContract,
safety~ : SafetyContract,
) -> ObjectContract {
{ id, kind, schema, timing, safety, bindings: [] }
}
///|
pub fn ObjectContract::with_binding(
self : ObjectContract,
binding : ObjectBinding,
) -> ObjectContract {
let bindings : Array[ObjectBinding] = []
for item in self.bindings {
bindings.push(item)
}
bindings.push(binding)
{
id: self.id,
kind: self.kind,
schema: self.schema,
timing: self.timing,
safety: self.safety,
bindings,
}
}
///|
pub(all) struct ContractRegistry {
objects : Array[ObjectContract]
} derive(Debug)
///|
pub fn ContractRegistry::new() -> ContractRegistry {
{ objects: [] }
}
///|
pub fn ContractRegistry::with_object(
self : ContractRegistry,
contract : ObjectContract,
) -> ContractRegistry {
let objects : Array[ObjectContract] = []
for item in self.objects {
objects.push(item)
}
objects.push(contract)
{ objects, }
}
///|
pub fn ContractRegistry::copy(self : ContractRegistry) -> ContractRegistry {
let objects = self.objects.map(object => {
..object,
bindings: object.bindings.copy(),
})
{ objects, }
}
///|
pub fn ContractRegistry::register(
self : ContractRegistry,
contract : ObjectContract,
) -> Unit {
self.objects.push(contract)
}
///|
pub fn ContractRegistry::contains_object(
self : ContractRegistry,
object_id : String,
) -> Bool {
let mut found = false
for object in self.objects {
if object.id == object_id {
found = true
}
}
found
}
///|
pub(all) struct ContractLintIssue {
object_id : String?
binding_id : String?
message : String
} derive(Eq, Debug)
///|
pub(all) struct ContractLintReport {
issues : Array[ContractLintIssue]
} derive(Eq, Debug)
///|
pub fn ContractLintReport::passes(self : ContractLintReport) -> Bool {
self.issues.length() == 0
}
///|
pub fn ContractRegistry::lint(self : ContractRegistry) -> ContractLintReport {
let issues : Array[ContractLintIssue] = []
for i in 0.. Unit {
push_negative_timing(issues, object.id, "period_ns", object.timing.period_ns)
push_negative_timing(
issues,
object.id,
"max_age_ns",
object.timing.max_age_ns,
)
push_negative_timing(
issues,
object.id,
"deadline_ns",
object.timing.deadline_ns,
)
push_negative_timing(
issues,
object.id,
"valid_for_ns",
object.timing.valid_for_ns,
)
match object.kind {
Command =>
if object.timing.valid_for_ns is None && object.timing.deadline_ns is None {
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "command object requires valid_for_ns or deadline_ns",
})
}
Signal =>
if object.timing.max_age_ns is None {
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "signal object requires max_age_ns",
})
}
Event =>
if object.timing.max_age_ns is None && object.timing.deadline_ns is None {
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "event object requires max_age_ns or deadline_ns",
})
}
_ => ()
}
}
///|
fn push_negative_timing(
issues : Array[ContractLintIssue],
object_id : String,
field : String,
value : Int64?,
) -> Unit {
match value {
Some(v) if v < 0L =>
issues.push({
object_id: Some(object_id),
binding_id: None,
message: field + " must be >= 0",
})
_ => ()
}
}
///|
fn lint_safety(
issues : Array[ContractLintIssue],
object : ObjectContract,
) -> Unit {
if object.safety.timeout_action.trim().length() == 0 {
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "timeout_action must not be empty",
})
}
if object.kind is Command && object.safety.authority is ReadOnly {
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "command object cannot be read_only",
})
}
if object.kind is Command && object.safety.authority is Any {
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "command object requires explicit authority",
})
}
match (object.safety.min_payload_digest, object.safety.max_payload_digest) {
(Some(min_value), Some(max_value)) if min_value > max_value =>
issues.push({
object_id: Some(object.id),
binding_id: None,
message: "min_payload_digest exceeds max_payload_digest",
})
_ => ()
}
}
///|
fn lint_binding_shape(
issues : Array[ContractLintIssue],
object : ObjectContract,
binding : ObjectBinding,
) -> Unit {
match binding.kind {
TraceLabel => ()
CoeSdo => {
require_binding_index(issues, object.id, binding)
require_binding_subindex(issues, object.id, binding)
}
CanopenSdo => {
require_binding_index(issues, object.id, binding)
require_binding_subindex(issues, object.id, binding)
require_binding_channel(issues, object.id, binding)
}
EtherCatPdo | CanopenPdo => {
require_binding_index(issues, object.id, binding)
require_binding_subindex(issues, object.id, binding)
require_binding_channel(issues, object.id, binding)
}
ZenohKey =>
if binding.key == "" {
issues.push({
object_id: Some(object.id),
binding_id: Some(binding.binding_id),
message: "zenoh_key binding requires key",
})
}
ModbusRegister => {
require_binding_index(issues, object.id, binding)
require_binding_channel(issues, object.id, binding)
}
}
}
///|
fn require_binding_index(
issues : Array[ContractLintIssue],
object_id : String,
binding : ObjectBinding,
) -> Unit {
if binding.index is None {
issues.push({
object_id: Some(object_id),
binding_id: Some(binding.binding_id),
message: binding.kind.label() + " binding requires index",
})
}
}
///|
fn require_binding_subindex(
issues : Array[ContractLintIssue],
object_id : String,
binding : ObjectBinding,
) -> Unit {
if binding.subindex is None {
issues.push({
object_id: Some(object_id),
binding_id: Some(binding.binding_id),
message: binding.kind.label() + " binding requires subindex",
})
}
}
///|
fn require_binding_channel(
issues : Array[ContractLintIssue],
object_id : String,
binding : ObjectBinding,
) -> Unit {
if binding.channel == "" {
issues.push({
object_id: Some(object_id),
binding_id: Some(binding.binding_id),
message: binding.kind.label() + " binding requires channel",
})
}
}
///|
fn lint_binding_object_shape(
issues : Array[ContractLintIssue],
object : ObjectContract,
binding : ObjectBinding,
) -> Unit {
match object.kind {
Command =>
if !(binding.direction is Some(@trace.Tx)) {
issues.push({
object_id: Some(object.id),
binding_id: Some(binding.binding_id),
message: "command binding direction must be tx",
})
}
Signal =>
if !(binding.direction is Some(@trace.Rx)) {
issues.push({
object_id: Some(object.id),
binding_id: Some(binding.binding_id),
message: "signal binding direction must be rx",
})
}
Event =>
if !(binding.direction is Some(@trace.Rx) ||
binding.direction is Some(@trace.Fault)) {
issues.push({
object_id: Some(object.id),
binding_id: Some(binding.binding_id),
message: "event binding direction must be rx or fault",
})
}
_ => ()
}
}
///|
pub(all) enum ObjectViolationKind {
AuthorityViolation
TimingViolation
PayloadRangeViolation
TerminalFaultViolation
} derive(Eq, Debug)
///|
pub fn ObjectViolationKind::label(self : ObjectViolationKind) -> String {
match self {
AuthorityViolation => "authority"
TimingViolation => "timing"
PayloadRangeViolation => "payload-range"
TerminalFaultViolation => "terminal-fault"
}
}
///|
pub(all) struct ObjectViolation {
kind : ObjectViolationKind
object_id : String
event_id : Int?
reason : String
} derive(Eq, Debug)
///|
pub(all) struct ObjectMonitorReport {
checked_events : Int
violations : Array[ObjectViolation]
} derive(Eq, Debug)
///|
pub fn ObjectMonitorReport::passes(self : ObjectMonitorReport) -> Bool {
self.violations.length() == 0
}
///|
pub(all) struct ObjectTimeoutActionObservation {
object_id : String
trigger_event_id : Int
action : String
} derive(Eq, Debug)
///|
pub fn ObjectTimeoutActionObservation::make(
object_id~ : String,
trigger_event_id~ : Int,
action~ : String,
) -> ObjectTimeoutActionObservation {
{ object_id, trigger_event_id, action }
}
///|
pub(all) enum ObjectTimeoutActionStatus {
TimeoutActionMatched
TimeoutActionMissing
TimeoutActionMismatch
TimeoutActionDuplicate
TimeoutActionOrphan
} derive(Eq, Debug)
///|
pub fn ObjectTimeoutActionStatus::label(
self : ObjectTimeoutActionStatus,
) -> String {
match self {
TimeoutActionMatched => "matched"
TimeoutActionMissing => "missing"
TimeoutActionMismatch => "mismatch"
TimeoutActionDuplicate => "duplicate"
TimeoutActionOrphan => "orphan"
}
}
///|
pub(all) struct ObjectTimeoutActionEvaluation {
object_id : String
trigger_event_id : Int
expected_action : String
observed_action : String
observation_count : Int
status : ObjectTimeoutActionStatus
} derive(Eq, Debug)
///|
pub(all) struct ObjectMonitorAssessment {
object_report : ObjectMonitorReport
timeout_action_observation_count : Int
timeout_action_evaluations : Array[ObjectTimeoutActionEvaluation]
timeout_action_matched_count : Int
timeout_action_missing_count : Int
timeout_action_mismatch_count : Int
timeout_action_duplicate_count : Int
timeout_action_orphan_count : Int
} derive(Eq, Debug)
///|
pub fn ObjectMonitorAssessment::passes(self : ObjectMonitorAssessment) -> Bool {
self.object_report.passes() &&
self.timeout_action_missing_count == 0 &&
self.timeout_action_mismatch_count == 0 &&
self.timeout_action_duplicate_count == 0 &&
self.timeout_action_orphan_count == 0
}
///|
pub(all) struct ObjectMonitor {
registry : ContractRegistry
} derive(Debug)
///|
pub fn ObjectMonitor::new(registry~ : ContractRegistry) -> ObjectMonitor {
{ registry, }
}
///|
pub fn ObjectMonitor::check_trace(
self : ObjectMonitor,
log : @trace.TraceLog,
) -> ObjectMonitorReport {
let violations : Array[ObjectViolation] = []
let mut checked_events = 0
let mut terminal_fault_seen = false
for event in log.events {
if event.direction == @trace.Fault {
terminal_fault_seen = true
}
for object in self.registry.objects {
for binding in object.bindings {
if binding.matches(event) {
checked_events += 1
check_authority(violations, object, binding, event)
check_timing(violations, object, event)
check_payload_digest_range(violations, object, event)
if object.kind is Command && terminal_fault_seen {
violations.push({
kind: TerminalFaultViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "command observed after terminal fault",
})
}
}
}
}
}
{ checked_events, violations }
}
///|
pub fn ObjectMonitor::assess_trace(
self : ObjectMonitor,
log : @trace.TraceLog,
timeout_actions : ArrayView[ObjectTimeoutActionObservation],
) -> ObjectMonitorAssessment {
let object_report = self.check_trace(log)
let evaluations = evaluate_timeout_actions(
self.registry,
object_report.violations,
timeout_actions,
)
{
object_report,
timeout_action_observation_count: timeout_actions.length(),
timeout_action_evaluations: evaluations,
timeout_action_matched_count: count_timeout_action_status(
evaluations,
TimeoutActionMatched,
),
timeout_action_missing_count: count_timeout_action_status(
evaluations,
TimeoutActionMissing,
),
timeout_action_mismatch_count: count_timeout_action_status(
evaluations,
TimeoutActionMismatch,
),
timeout_action_duplicate_count: count_timeout_action_status(
evaluations,
TimeoutActionDuplicate,
),
timeout_action_orphan_count: count_timeout_action_status(
evaluations,
TimeoutActionOrphan,
),
}
}
///|
fn check_authority(
violations : Array[ObjectViolation],
object : ObjectContract,
binding : ObjectBinding,
event : @trace.TraceEvent,
) -> Unit {
if object.kind is Command {
let authority = match binding.authority {
Any => object.safety.authority
other => other
}
if !authority.allows(event.node_id) {
violations.push({
kind: AuthorityViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "authority denied for node " + event.node_id,
})
}
}
}
///|
fn check_timing(
violations : Array[ObjectViolation],
object : ObjectContract,
event : @trace.TraceEvent,
) -> Unit {
let age_ns = event_age_ns(event)
match object.timing.max_age_ns {
Some(limit) if age_ns > limit =>
violations.push({
kind: TimingViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "max_age_ns exceeded",
})
_ => ()
}
match object.timing.valid_for_ns {
Some(limit) if age_ns > limit =>
violations.push({
kind: TimingViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "valid_for_ns exceeded",
})
_ => ()
}
match object.timing.deadline_ns {
Some(deadline) if age_ns > deadline =>
violations.push({
kind: TimingViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "deadline_ns exceeded",
})
_ => ()
}
}
///|
fn check_payload_digest_range(
violations : Array[ObjectViolation],
object : ObjectContract,
event : @trace.TraceEvent,
) -> Unit {
match event.payload_digest {
Some(digest) => {
match object.safety.min_payload_digest {
Some(min_value) if digest < min_value =>
violations.push({
kind: PayloadRangeViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "payload digest below range",
})
_ => ()
}
match object.safety.max_payload_digest {
Some(max_value) if digest > max_value =>
violations.push({
kind: PayloadRangeViolation,
object_id: object.id,
event_id: Some(event.event_id),
reason: "payload digest above range",
})
_ => ()
}
}
None => ()
}
}
///|
fn event_age_ns(event : @trace.TraceEvent) -> Int64 {
let age = event.raw_ns - event.vtime.ns()
if age < 0L {
0L
} else {
age
}
}
///|
fn evaluate_timeout_actions(
registry : ContractRegistry,
violations : ArrayView[ObjectViolation],
observations : ArrayView[ObjectTimeoutActionObservation],
) -> Array[ObjectTimeoutActionEvaluation] {
let evaluations : Array[ObjectTimeoutActionEvaluation] = []
let required_objects : Array[String] = []
let required_events : Array[Int] = []
for violation in violations {
guard violation.kind == TimingViolation else { continue }
guard violation.event_id is Some(event_id) else { continue }
if !object_event_pair_exists(
required_objects,
required_events,
violation.object_id,
event_id,
) {
required_objects.push(violation.object_id)
required_events.push(event_id)
}
}
for i in 0.. 1 {
TimeoutActionDuplicate
} else if observed == expected {
TimeoutActionMatched
} else {
TimeoutActionMismatch
}
evaluations.push({
object_id,
trigger_event_id: event_id,
expected_action: expected,
observed_action: observed,
observation_count: matches.length(),
status,
})
}
let processed_objects : Array[String] = []
let processed_events : Array[Int] = []
for observation in observations {
if object_event_pair_exists(
required_objects,
required_events,
observation.object_id,
observation.trigger_event_id,
) ||
object_event_pair_exists(
processed_objects,
processed_events,
observation.object_id,
observation.trigger_event_id,
) {
continue
}
let matches = timeout_action_observations_for(
observations,
observation.object_id,
observation.trigger_event_id,
)
evaluations.push({
object_id: observation.object_id,
trigger_event_id: observation.trigger_event_id,
expected_action: "",
observed_action: observation.action,
observation_count: matches.length(),
status: if matches.length() > 1 {
TimeoutActionDuplicate
} else {
TimeoutActionOrphan
},
})
processed_objects.push(observation.object_id)
processed_events.push(observation.trigger_event_id)
}
evaluations
}
///|
fn object_event_pair_exists(
object_ids : ArrayView[String],
event_ids : ArrayView[Int],
object_id : String,
event_id : Int,
) -> Bool {
for i in 0.. Array[ObjectTimeoutActionObservation] {
observations.filter(fn(observation) {
observation.object_id == object_id &&
observation.trigger_event_id == event_id
})
}
///|
fn contract_timeout_action(
registry : ContractRegistry,
object_id : String,
) -> String {
for object in registry.objects {
if object.id == object_id {
return object.safety.timeout_action
}
}
""
}
///|
fn count_timeout_action_status(
evaluations : ArrayView[ObjectTimeoutActionEvaluation],
status : ObjectTimeoutActionStatus,
) -> Int {
evaluations.filter(fn(evaluation) { evaluation.status == status }).length()
}
///|
pub fn axis_authority_contracts() -> ContractRegistry {
let controller = Role("controller")
let actual_position = ObjectContract::make(
id="axis.actual_position",
kind=Signal,
schema=I32,
timing=TimingContract::make(period_ns=1_000_000L, max_age_ns=2_000_000L),
safety=SafetyContract::make(authority=ReadOnly),
).with_binding(
ObjectBinding::make(
object_id="axis.actual_position",
binding_id="trace.axis.actual_position",
medium_id="trace",
label="axis.actual_position.signal",
direction=@trace.Rx,
),
)
let target_position = ObjectContract::make(
id="axis.target_position",
kind=Command,
schema=I32,
timing=TimingContract::make(max_age_ns=1_000_000L, valid_for_ns=1_000_000L),
safety=SafetyContract::make(
authority=controller,
min_payload_digest=0,
max_payload_digest=1_000_000,
timeout_action="hold_last",
),
).with_binding(
ObjectBinding::make(
object_id="axis.target_position",
binding_id="trace.axis.target_position",
medium_id="trace",
label="axis.target_position.command",
direction=@trace.Tx,
authority=controller,
),
)
let statusword = ObjectContract::make(
id="axis.statusword",
kind=Signal,
schema=U16,
timing=TimingContract::make(period_ns=1_000_000L, max_age_ns=2_000_000L),
safety=SafetyContract::make(authority=ReadOnly),
).with_binding(
ObjectBinding::make(
object_id="axis.statusword",
binding_id="trace.axis.statusword",
medium_id="trace",
label="axis.statusword.ack",
direction=@trace.Rx,
),
)
let lifecycle = ObjectContract::make(
id="axis.lifecycle",
kind=Lifecycle,
schema=State,
timing=TimingContract::make(max_age_ns=5_000_000L),
safety=SafetyContract::make(authority=controller),
)
.with_binding(
ObjectBinding::make(
object_id="axis.lifecycle",
binding_id="trace.axis.lifecycle.enable",
medium_id="trace",
label="axis.lifecycle.enable",
direction=@trace.Tx,
authority=controller,
),
)
.with_binding(
ObjectBinding::make(
object_id="axis.lifecycle",
binding_id="trace.axis.lifecycle.fault",
medium_id="trace",
label="axis.lifecycle.fault",
direction=@trace.Fault,
),
)
ContractRegistry::new()
.with_object(actual_position)
.with_object(target_position)
.with_object(statusword)
.with_object(lifecycle)
}