Skip to content

Commit 47fef42

Browse files
reid-spencerclaude
andcommitted
[4.1] The ruling reaches the AST accessor too; [4.4] count coherence
Two corrections from Reid, both of which I had got wrong. [4.1] REACHES FURTHER than I implemented. Reid: *"Is an entity with portlets a streamlet? Yes, it is. Is a projector with inlets and outlets a streamlet? Yes it is. So when a definition includes WithStreamlets, it includes all the processor definitions in its purview that have portlets."* I had deliberately left `WithStreamlets.streamlets` alone and asked whether it was in scope; it was. So a streamlet is now defined by a NON-ZERO PORTLET COUNT, not by which keyword declared it — and that definition is applied everywhere, including narrowing the three output accessors I had over-widened to "all processors" yesterday. `checkStreaming` needed nothing: `StreamingValidation` was already widened to every Processor kind in 70b0f52. The accessor was the last narrow piece. ONE RULE WAS RIDING ON THE OLD NARROWNESS and had to be written out. The "context with entities needs a Sink" check tested `c.streamlets.exists(_.inlets.nonEmpty)`, and its own suggestion says "an entity's own inlet does not satisfy this — driving an entity from outside IS an inbound stream and belongs at the context boundary". Once entities became streamlets, an entity inlet would have satisfied it silently. The exclusion is now in the code, not just the prose. The consequence to know: a PORTLESS processor is not a streamlet, including a stubbed or `void` Streamlet. Three tests asserted the old meaning and now say what they mean — `source src is { ??? }` has no ports, so it is a Streamlet declaration but not a streamlet. Data-flow diagrams keep portless Streamlet stubs in the GATE (so a stub context does not lose its diagram) while keeping them out of the DATA. [4.4] I measured the wrong thing. Reid: *"they shouldn't drop if the shape is consistent with the number of inlets and outlets; having those line up is AN INDICATOR of maturity."* I had counted `ascribedShape.nonEmpty`, which scores whether the author TYPED something rather than whether the model hangs together, and would have penalised every correct model that leaves the shape implicit. It now scores COHERENCE: an ascription that matches the arity counts, an absent one counts (it cannot disagree), a contradicting one does not. Compared by KEYWORD, not `==`: `StreamletShape` carries a `loc` and `arityShape` builds one at the processor's location, so `==` would have been false for every processor alive — a corpus-wide maturity collapse that would have looked like a finding. language 731, passes 1453, riddlLib 144, commands 245 green; corpus validation-parity holds at 190/190. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1c4dc1f commit 47fef42

11 files changed

Lines changed: 97 additions & 40 deletions

File tree

language/src/main/scala/com/ossuminc/riddl/language/AST.scala

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,8 +749,25 @@ object AST:
749749
/** Base trait to use in any [[Definition]] that can define [[Streamlet]]s */
750750
sealed trait WithStreamlets[CV <: RiddlValue] extends Container[CV]:
751751

752-
/** A lazily constructed [[Seq]] of [[Streamlet]] filtered from the contents */
753-
def streamlets: Seq[Streamlet] = contents.filterThroughWrappers[Streamlet]
752+
/** Every STREAMLET in this container's purview — which since [4.1] means **every contained
753+
* [[Processor]] with a non-zero portlet count**, not merely the [[Streamlet]] case class.
754+
*
755+
* **RULED 2026-08-17 by Reid:** *"Is an entity with portlets a streamlet? Yes, it is. Is a
756+
* projector with inlets and outlets a streamlet? Yes it is. So when a definition includes
757+
* WithStreamlets, it includes all the processor definitions in its purview that have portlets,
758+
* including entities, repositories, projectors."*
759+
*
760+
* A streamlet is therefore defined by what it HAS, not by which keyword declared it. That is
761+
* the whole content of the unified processor model: `Streamlet` was the port-bearing kind
762+
* until every processor became port-bearing, at which point the case class stopped being the
763+
* useful boundary and the portlet count started being it.
764+
*
765+
* **A processor with no portlets is NOT a streamlet**, including a `void`-shaped or stubbed
766+
* `Streamlet` — it has nothing in a stream to be. Callers wanting the case class regardless
767+
* write `.collect { case s: Streamlet => s }` on `contents`.
768+
*/
769+
def streamlets: Seq[Processor[?]] =
770+
contents.filterThroughWrappers[Processor[?]].filter(_.ports.nonEmpty)
754771
end WithStreamlets
755772

756773
/** Base trait to use in any [[Definition]] that can define [[Connector]]s */

language/src/test/scala/com/ossuminc/riddl/language/ASTTest.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ class ASTTest extends AbstractTestingBasis {
559559
val connector = Connector(At.empty, Identifier(At.empty, "channel"), outletRef, inletRef)
560560
val plant =
561561
Context(At.empty, Identifier(At.empty, "plant"), Contents(source, sink, connector))
562+
// [4.1]: a streamlet is any processor WITH PORTLETS, so this asserts on the port-bearing set.
562563
plant.streamlets.map(_.id.value) mustBe Seq("Source", "Sink")
563564
plant.connectors.map(_.id.value) mustBe Seq("channel")
564565
source.effectiveShape mustBe Source(At.empty)

language/src/test/scala/com/ossuminc/riddl/language/parsing/ParsingTestTest.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,12 @@ abstract class ParsingTestTest(using PlatformContext) extends AbstractParsingTes
151151

152152
"parseTopLevelDomain[Processor]" in { (td: TestData) =>
153153
val input = RiddlParserInput("domain foo is { context C is { source X is { ??? } } }", td)
154-
parseTopLevelDomain[Streamlet](input, _.domains.head.contexts.head.streamlets.head) match {
154+
// [4.1]: `streamlets` means processors WITH PORTLETS, and `source X is { ??? }` has none,
155+
// so this case asks the contents for the Streamlet declaration rather than the accessor.
156+
parseTopLevelDomain[Streamlet](
157+
input,
158+
_.domains.head.contexts.head.contents.filter[Streamlet].head
159+
) match {
155160
case Left(messages) => fail(messages.format)
156161
case Right((src, _)) => src.id.value mustBe "X"
157162
}

language/src/test/scala/com/ossuminc/riddl/language/parsing/StreamingParserTest.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ abstract class StreamingParserTest(using PlatformContext) extends AbstractParsin
262262
TopLevelParser.parseAsContext(input) match {
263263
case Left(errors) => fail(errors.format)
264264
case Right(context) =>
265-
val streamlet = context.streamlets.head
265+
// [4.1]: `context.streamlets` is every processor WITH PORTLETS, and `processor P is
266+
// { ??? }` declares none — so it is deliberately NOT a streamlet and this case must ask
267+
// for the declaration itself. That distinction is the ruling, not an accident of it.
268+
val streamlet = context.contents.filter[Streamlet].head
266269
streamlet.id.value mustBe "P"
267270
streamlet.ascribedShape mustBe None
268271
}

passes/src/main/scala/com/ossuminc/riddl/passes/analysis/AnalysisResult.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,14 @@ case class AnalysisResult(
189189
* and shipped in `2.0.0-rc.15`. It is DELETED rather than kept as a synonym: two names for one
190190
* answer is how a consumer ends up believing they differ.
191191
*
192+
* A streamlet is defined by a NON-ZERO PORTLET COUNT, not by which keyword declared it, so a
193+
* portless processor — including a stubbed or `void`-shaped `Streamlet` — is not one.
194+
*
192195
* **Breaking**: the element type widened from `Streamlet` to `Processor[?]`. A caller wanting
193196
* only the `Streamlet` case class writes `.collect { case s: Streamlet => s }`.
194197
*/
195198
def streamlets: Seq[Processor[?]] =
196-
symbols.parentage.keys.collect { case p: Processor[?] => p }.toSeq
199+
symbols.parentage.keys.collect { case p: Processor[?] if p.ports.nonEmpty => p }.toSeq
197200

198201
/** Get all projectors in the model */
199202
def projectors: Seq[Projector] =

passes/src/main/scala/com/ossuminc/riddl/passes/diagrams/DiagramsPass.scala

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -499,11 +499,11 @@ class DiagramsPass(input: PassInput, outputs: PassesOutput)(using PlatformContex
499499
// `filterThroughWrappers`, not `contents.processors`: the latter is deliberately LITERAL (see
500500
// its note in Contents.scala), and this must see an included processor exactly as the AST's
501501
// own `context.streamlets` containment accessor does.
502-
val ctxProcessors = context.contents.filterThroughWrappers[Processor[?]]
503-
// The DIAGRAM GATE stays on processors that actually declare a port. `ctxProcessors` is now
504-
// true of nearly every context, so gating on it would emit a data-flow diagram for every
505-
// context in the model, most of them with nothing to draw.
506-
val ctxWithPorts = ctxProcessors.filter(p => p.inlets.nonEmpty || p.outlets.nonEmpty)
502+
// [4.1]: `context.streamlets` IS this question now — every contained processor with a
503+
// non-zero portlet count — so ask the accessor rather than re-deriving it here. The manual
504+
// `filterThroughWrappers` that stood here was doing the accessor's job while the accessor was
505+
// still narrow.
506+
val ctxStreamlets = context.streamlets
507507
val connections = ctxConnectors.flatMap { connector =>
508508
if connector.nonEmpty then
509509
val maybeOutlet = refMap.definitionOf[Outlet](connector.from, context)
@@ -526,13 +526,17 @@ class DiagramsPass(input: PassInput, outputs: PassesOutput)(using PlatformContex
526526
case _ => None
527527
else None
528528
}
529-
// A portless Streamlet (a stub, or a `void` shape) still earns a diagram, as it did before
530-
// [4.1] widened the field: it is a streaming component whose ports are simply not written yet.
531-
val ctxStreamletStubs = ctxProcessors.collect { case s: Streamlet => s }
532-
if ctxConnectors.nonEmpty || ctxWithPorts.nonEmpty || ctxStreamletStubs.nonEmpty then
529+
// A portless `Streamlet` (a stub, or a `void` shape) is NOT a streamlet under [4.1], but it is
530+
// still a streaming component whose ports are merely unwritten, and a context holding one
531+
// produced a data flow diagram before this change. Keep it in the GATE so a stub context does
532+
// not silently lose its diagram — while keeping it out of the DATA, which now answers the
533+
// portlet question exactly.
534+
val ctxStreamletStubs =
535+
context.contents.filterThroughWrappers[Streamlet].filter(_.ports.isEmpty)
536+
if ctxConnectors.nonEmpty || ctxStreamlets.nonEmpty || ctxStreamletStubs.nonEmpty then
533537
dataFlowDiagrams.put(
534538
context,
535-
DataFlowDiagramData(context, ctxConnectors, ctxProcessors, connections)
539+
DataFlowDiagramData(context, ctxConnectors, ctxStreamlets, connections)
536540
)
537541
}
538542

passes/src/main/scala/com/ossuminc/riddl/passes/stats/StatsPass.scala

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -406,17 +406,25 @@ case class StatsPass(input: PassInput, outputs: PassesOutput)(using PlatformCont
406406
if p.handlers.nonEmpty then result += 1
407407
if p.functions.nonEmpty then result += 1
408408
if p.constants.nonEmpty then result += 1
409-
// [4.4], RULED 2026-08-17 by Reid (option B): a shape is a specification EVERY processor may
410-
// complete, not only a Streamlet -- since the unified processor model, any of the six kinds
411-
// may carry an `ascribedShape`. Counted here rather than per-kind below so the numerator and
412-
// the denominator (`specsForProcessor`) move together; they were previously both
413-
// Streamlet-only, at two sites that had to be kept in step by hand.
409+
// [4.4], RULED 2026-08-17 by Reid (option B), then CORRECTED by him the same day:
410+
// *"they shouldn't drop if the shape is consistent with the number of inlets and outlets;
411+
// having those line up is AN INDICATOR of maturity, so make it count that way."*
414412
//
415-
// A Streamlet still counts unconditionally. Its shape is REQUIRED and is known even when
416-
// `ascribedShape` is None, because the keyword form (`source X is …`) states it and the
417-
// arity derives it -- so testing `ascribedShape` alone would have SILENTLY DROPPED every
418-
// keyword-declared streamlet's score from 1 to 0, which is a regression dressed as a ruling.
419-
if p.ascribedShape.nonEmpty || p.isInstanceOf[Streamlet] then result += 1
413+
// So the credit is for COHERENCE, not for the mere presence of an `as <shape>` clause. A
414+
// processor whose ascribed shape matches its arity has said something true about itself and
415+
// scores; one whose ascription CONTRADICTS its ports has not, and does not. A processor that
416+
// ascribes nothing scores too — its shape is derived from arity and so cannot disagree with
417+
// it — which is what keeps percentages from dropping across the corpus for models that simply
418+
// never write the clause.
419+
//
420+
// My first implementation counted `ascribedShape.nonEmpty`, which measured whether the author
421+
// had TYPED something rather than whether the model hangs together, and would have penalised
422+
// every correct model that leaves the shape implicit.
423+
// Compared by KEYWORD, not by `==`. `StreamletShape` is a case class carrying a `loc`, and
424+
// `arityShape` builds one at the PROCESSOR's location while an ascribed shape carries its own
425+
// — so `==` would be false for every processor alive, scoring zero everywhere and looking
426+
// like a corpus-wide maturity collapse.
427+
if p.ascribedShape.forall(_.keyword == p.arityShape.keyword) then result += 1
420428
result
421429

422430
private def completedCount(v: RiddlValue): Int = {

passes/src/main/scala/com/ossuminc/riddl/passes/validate/ValidationPass.scala

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,11 @@ case class ValidationPass(
9999
inlets.toSeq,
100100
outlets.toSeq,
101101
connectors.toSeq,
102-
// [4.1], RULED 2026-08-17: `streamlets` means ALL port-bearing processors, so the graph's
103-
// node buffer -- which is already every Processor kind -- passes through UNNARROWED. The
104-
// `.collect { case s: Streamlet => s }` that used to sit here was the narrowing.
105-
processors.toSeq,
102+
// [4.1], RULED 2026-08-17: a streamlet is any processor with a NON-ZERO PORTLET COUNT. The
103+
// graph's node buffer is already every Processor kind, so the old
104+
// `.collect { case s: Streamlet => s }` narrowing is gone -- but the portlet test is real,
105+
// not a formality: a processor with no ports is not in a stream.
106+
processors.toSeq.filter(_.ports.nonEmpty),
106107
computedHandlerCompleteness,
107108
deliverableTypes.toMap
108109
)
@@ -4526,7 +4527,15 @@ case class ValidationPass(
45264527
val nonEmptyEntities = c.entities.filter(_.nonEmpty)
45274528
if nonEmptyEntities.nonEmpty && c.nonEmpty then {
45284529
// Completeness 4i: context with entities must have a Sink
4529-
val hasSinkOrInlet = c.streamlets.exists(_.inlets.nonEmpty)
4530+
// [4.1]: `c.streamlets` now includes ANY port-bearing processor, entities included — so the
4531+
// Entity exclusion this check has always relied on must be written out. It used to ride on
4532+
// the accessor being narrow, which is precisely the kind of rule that disappears when an
4533+
// accessor is widened underneath it. The suggestion below states the rule; now the code does
4534+
// too: driving an entity from outside IS an inbound stream and belongs at the context
4535+
// boundary where it can be seen.
4536+
val hasSinkOrInlet = c.streamlets.exists { s =>
4537+
s.inlets.nonEmpty && !s.isInstanceOf[Entity]
4538+
}
45304539
if !hasSinkOrInlet then {
45314540
messages.addCompleteness(
45324541
c.errorLoc,

passes/src/test/scala-jvm-native/com/ossuminc/riddl/passes/analysis/AnalysisPassSpec.scala

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,12 @@ class AnalysisPassSpec extends AnyWordSpec with Matchers {
128128
result.sagas mustBe empty
129129
result.epics mustBe empty
130130
result.repositories mustBe empty
131-
// [4.1], RULED 2026-08-17: `streamlets` means every PORT-BEARING processor, which since
132-
// the unified processor model is every Processor kind. This model declares no `Streamlet`
133-
// but does declare 2 contexts and 2 entities, and those ARE processors — so the old
134-
// `mustBe empty` was asserting the narrow reading, not an empty model.
135-
result.streamlets.map(_.id.value).toSet mustBe
136-
Set("Orders", "Customers", "Order", "Customer")
137-
result.streamlets.collect { case s: Streamlet => s } mustBe empty
131+
// [4.1], RULED 2026-08-17: a streamlet is any processor with a NON-ZERO PORTLET COUNT.
132+
// This model's contexts and entities declare no inlets or outlets, so none of them is a
133+
// streamlet and the answer is empty — the same result as before the ruling, for a
134+
// completely different reason. Worth stating: the old assertion passed because nothing
135+
// here is a `Streamlet` case class; this one passes because nothing here has a port.
136+
result.streamlets mustBe empty
138137
result.projectors mustBe empty
139138
result.adaptors mustBe empty
140139
result.functions mustBe empty

passes/src/test/scala-jvm-native/com/ossuminc/riddl/passes/resolve/ReferenceMapTest.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,12 @@ class ReferenceMapTest extends AbstractValidatingTest {
7979
refMap.definitionOf[Inlet](pid) match {
8080
case Some(actual: Inlet) =>
8181
actual.id.value mustBe ("InCommands")
82+
// [4.1]: `streamlets` is `Seq[Processor[?]]` now, so this match is no longer exhaustive
83+
// on `Some(_: Streamlet)` — which `-Werror` caught. The case wants the port-bearing
84+
// processor named "Sink" whatever kind declared it, so it matches on the wider type.
8285
val expected = context.streamlets.find("Sink")
8386
expected match {
84-
case Some(streamlet: Streamlet) =>
87+
case Some(streamlet) =>
8588
streamlet.id.value mustBe ("Sink")
8689
streamlet.inlets must (not be (empty))
8790
val expected = streamlet.inlets.head

0 commit comments

Comments
 (0)