1
0
Fork 0
mirror of https://github.com/IRS-Public/direct-file.git synced 2025-08-16 09:40:53 +00:00

initial commit

This commit is contained in:
sps-irs 2025-05-29 13:12:11 -04:00 committed by Alexander Petros
parent 2f3ebd6693
commit e0d5c84451
3413 changed files with 794524 additions and 1 deletions

View file

@ -0,0 +1,4 @@
version = "3.8.3"
runner.dialect = scala3
maxColumn = 120
rewrite.trailingCommas.style = always

View file

@ -0,0 +1,97 @@
# Fact Graph Scala
This repo holds the Scala code that is compiled to Java and transpiled to JS. The project uses
- Java 21
- Scala 3
- ScalaJS to transpile to JS
- SBT
## Installation
- (this section needs some love)
- install coursier (will download, and run AdoptOpenJDK11)
- change to version 21 with `eval "$(cs java --jvm 21 --env)"`
- set up sbt using coursier
- install [metals](https://marketplace.visualstudio.com/items?itemName=scalameta.metals) extension for your IDE
## General Usage
**Compile** the main sources in `src/main/scala` using `sbt compile`
**Delete** all generated files in the target directory using `sbt clean`
To clean and compile, use a one liner: `sbt clean compile`
**Console Access** Start the scala interpereter `sbt console`
**Reload** reload the build definition (build.sbt) `sbt reload`
**Run (not currently a feature)** eventually, using `sbt run` will run the main class for the project
**Test** using `sbt test` or to test only a single file use `sbt testOnly tinSpec.scala`
### How to get your Scala changes to the client
#### Quickstart
From `/direct-file/direct-file/fact-graph-scala`
1. run `sbt` (to drop into the sbt shell)
2. Let's say you are working on the Tin component and you updated the TinSpec. It would be quicker to run the TinSpec test ONLY rather than all the tests. This can be accomplished by running `testOnly *TinSpec`. Place any test name after the wildcard (*) and all matches will run. E.g, `testOnly *PinSpec` would run both the PinSpec and the IpPinSpec.
3. run `test` to test everything
4. run `scalafmt` to format the source
5. run `compile`
6. run `clean`
7. run `fastOptJS` to transpile Scala to JS
8. run `exit` to leave the sbt shell
9. `cd` back to `df-client-app`
10. if the client is running in another terminal, kill it (CTRL+C)
11. run `npm run copy-transpiled-js` to get the transpiled files
12. run `docker-compose build` to get the latest backend scala changes (depending on the Scala change this could be optional)
13. run `npm run start` to start the client
14. Load the checklist ( to load the factgraph on the client)
15. use `debugFactGraphScala…` to test the new functionality (in the browser console). This is a key step to see if your Scala changes came through. E.g, if I needed to modify the IpPinFactory to allow all zeros to fail, I could do implement the change in Scala. Go through steps 1-10 above and then type `debugFactGraphScala.IpPinFactory('0000000')` in the browser console to see if this fails now (as it should). If it does, you can rest assured that your Scala changes have been transpiled and copied successfully to the factgraph on the client.
16. When adding new Scala changes to the client, go to step 8.
17. start client integration
#### Testing in VS Code
While Step 2 (above) allows us to test an individual file (some set of unit tests), what if we wanted to test just a single unit, ie, a single `describe` block or single `it` block? The following allows us to do that in VS Code:
1. Open VS code from the `fact-graph-scala` directory
2. Click on the Metals extension. You can install it from [here](https://marketplace.visualstudio.com/items?itemName=scalameta.metals)
3. Under BUILD COMMANDS - click on `Import build` in Metals
4. Under PACKAGES - click on `factGraphJVM-test` folder in the navigator
5. Under ONGOING COMPILATIONS - wait for compilations to complete
6. Open any \*Spec file that you would like to run a single unit test against. The green arrow should show up along the file's line numbers vertically.
#### Details
For full transpilation, run `sbt fullOptJS`. For faster transpilation, run `sbt fastOptJS`. This will create the transpiled files. Copy transpiled files (main.js\*) from this repo to your local `js-factgraph-scala` within the df-client. This can be done two ways:
- in df-client-app run `npm run copy-transpiled-js` OR
- in fact-graph-scala directory run:
```
cp js/target/scala-3.2.0/fact-graph-opt/main.js* ../df-client/js-factgraph-scala/src/
```
Generate the main.mjs and main.mjs.map files into `js/target/scala-3.2.0/fact-graph-opt`: `sbt fullOptJS`
Note: `mjs` file type is used here to support ES6 modules used in scala tests.
For more information on the sbt-dotty plugin, see the [scala3-example-project](https://github.com/scala/scala3-example-project/blob/main/README.md).
#### Debug the factgraph in JS
There are some global variables exposed to be run in the console, after the factgraph loads, to troubleshoot:
```
> debugFactGraph
> debugFactGraphMeta
> debugScalaFactGraphLib
```
### Generating an SBOM
To generate a Software Bill of Materials (SBOM), run `sbt makeBom`. It will be missing some dependencies
that we will have to merge in from our `manual-scala-sbom.xml` (see that file for more information).

View file

@ -0,0 +1,24 @@
- catch up on unit tests
- Date type
- hard validations
- Option/MultiOption type
- derived MultiOption (i.e. eligible filing statuses)
- persist incomplete values
- soft validations
- cache and subscribe to facts/values
- constraints
- Metadata on FactDefinition
- XML schema
- load config from file
- JSON persister
- PDF export
- MeF export
- Excel export
- cycle detection
- clean up exceptions (consistency, messages, replacing throws with Trys)
- dictionary/definition versioning
- js interop
- java interop
- documentation
- config testing
- performance testing

View file

@ -0,0 +1,51 @@
import org.scalajs.linker.interface.OutputPatterns
val scala3Version = "3.3.3"
lazy val root = project
.in(file("."))
.aggregate(factGraph.js, factGraph.jvm)
.settings(
name := "fact-graph",
version := "0.1.0-SNAPSHOT",
organization := "gov.irs.factgraph",
scalaVersion := scala3Version,
publish := {},
publishLocal := {},
libraryDependencies += "org.scala-js" %% "scalajs-stubs" % "1.1.0" % "provided",
libraryDependencies += "com.lihaoyi" %% "upickle" % "3.1.0",
libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.15" % Test,
Test / testOptions += Tests.Argument("-oI")
)
// without extra libraries the javascript built is around 400kb.
lazy val factGraph = crossProject(JSPlatform, JVMPlatform)
.crossType(CrossType.Full)
.in(file("."))
.settings(
name := "fact-graph",
version := "0.1.0-SNAPSHOT",
organization := "gov.irs.factgraph",
scalaVersion := scala3Version,
scalaJSLinkerConfig ~= {
// We output CommonJSModules, which scalajs will put through the closure compiler in
// the `fullLinkJS` step. This outputs approximately 1mb of javascript, whereas the
// ESModules are 4mb. I tried outputting ESModules assuming that our build
// pipeline would eventually minify them with esbuild. However, esbuild turned
// out to be really, really bad at minifying these specific modules. So we're stuck
// on common js modules for now.
_.withModuleKind(ModuleKind.CommonJSModule)
},
libraryDependencies += "org.scala-js" %% "scalajs-stubs" % "1.1.0" % "provided",
// want to get rid of this eventually.
// just don't have time to implement my own dates now!
// it adds around 200kb to the built package.
libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.5.0",
libraryDependencies += "com.lihaoyi" %%% "upickle" % "3.1.0",
libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.15" % Test,
Test / testOptions += Tests.Argument("-oI")
)
.jvmSettings(
libraryDependencies += "org.scala-js" %% "scalajs-stubs" % "1.1.0" % "provided"
)
.jsSettings(
)

View file

@ -0,0 +1,44 @@
package gov.irs.factgraph
import scala.scalajs.js
import scala.scalajs.js.annotation.{JSExportTopLevel, JSExport}
import gov.irs.factgraph.definitions.fact.{
FactConfigElement,
WritableConfigElementDigestWrapper,
CompNodeConfigDigestWrapper,
CompNodeDigestWrapper,
}
@JSExportTopLevel("DigestNodeWrapper")
class DigestNodeWrapper(
val path: String,
val writable: WritableConfigElementDigestWrapper | Null,
val derived: CompNodeConfigDigestWrapper | Null,
val placeholder: CompNodeConfigDigestWrapper | Null,
) extends js.Object:
/** A digest-node is the JSON serialization of a Fact, as produced by the direct-file Java application, from the XML
* fact dictionary. This wrapper allows us to map digest JSON nodes into FactConfigElements, handling the necessary
* type conversion and null safety matches
*/
def writableOption = this.writable match
case null => None
case node =>
Some(WritableConfigElementDigestWrapper.toNative(node))
def derivedOption = this.derived match
case null => None
case _ => Some(CompNodeDigestWrapper.toNative(this.derived))
def placeholderOption = this.placeholder match
case null => None
case _ => Some(CompNodeDigestWrapper.toNative(this.placeholder))
@JSExportTopLevel("DigestNodeWrapperFactory")
object DigestNodeWrapper:
@JSExport
def toNative(wrapper: DigestNodeWrapper) = FactConfigElement(
wrapper.path,
wrapper.writableOption,
wrapper.derivedOption,
wrapper.placeholderOption,
)

View file

@ -0,0 +1,22 @@
package gov.irs.factgraph
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.definitions.FactDictionaryConfigTrait
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import gov.irs.factgraph.compnodes.RootNode
@JSExportTopLevel("FactDictionaryFactory")
object JSFactDictionary:
def apply(): FactDictionary =
val dictionary = new FactDictionary()
FactDefinition(RootNode(), Path.Root, Seq.empty, dictionary)
dictionary
@JSExport
def fromConfig(e: FactDictionaryConfigTrait): FactDictionary =
// A lot like the Scala fact dictionary factory, but uses the JSMeta, which
// contains useful methods the frontend needs
val dictionary = this()
JSMeta.fromConfig(e.meta, dictionary)
e.facts.map(FactDefinition.fromConfig(_)(using dictionary))
dictionary.freeze()
dictionary

View file

@ -0,0 +1,113 @@
package gov.irs.factgraph
import gov.irs.factgraph.persisters.*
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel, JSExportAll}
import scala.scalajs.js
import gov.irs.factgraph.limits.LimitViolation
import gov.irs.factgraph.definitions.fact.LimitLevel
import gov.irs.factgraph.monads.MaybeVector
import gov.irs.factgraph.monads.Result
@JSExportTopLevel("Graph")
@JSExportAll
class JSGraph(
override val dictionary: FactDictionary,
override val persister: Persister,
) extends Graph(dictionary, persister):
def toStringDictionary(): js.Dictionary[String] =
// This is a debug function to allow for quick inspection
// of the graph
import js.JSConverters._
this.persister.toStringMap().toJSDictionary
// Get a fact definition from the fact dictionary
// In scala this is graph.apply
def getFact(path: String) =
import js.JSConverters._
root.apply(Path(path)) match
case MaybeVector.Single(x) =>
x match
case Result.Complete(v) => v
case Result.Placeholder(v) => v
case Result.Incomplete => null
case MaybeVector.Multiple(vect, c) =>
throw new UnsupportedOperationException(
s"getFact returned multiple results for path $path, which is unsupported",
)
@JSExport("toJSON")
def toJson(indent: Int = -1): String =
this.persister.toJson(indent)
def explainAndSolve(path: String): js.Array[js.Array[String]] =
val rawExpl = this.explain(path)
import js.JSConverters._
return rawExpl.solves.map(l => l.map(p => p.toString).toJSArray).toJSArray
@JSExport("save")
def jsSave(): SaveReturnValue =
val rawSave = this.save();
import js.JSConverters._
return SaveReturnValue(
rawSave._1,
rawSave._2.map(f => LimitViolationWrapper.fromLimitViolation(f)).toJSArray,
)
@JSExport("checkPersister")
def jsCheckPersister(): js.Array[PersisterSyncIssueWrapper] =
val raw = this.checkPersister();
import js.JSConverters._
return raw.map(f => PersisterSyncIssueWrapper.fromPersisterSyncIssue(f)).toJSArray
@JSExportTopLevel("GraphFactory")
object JSGraph:
@JSExport("apply")
def apply(
dictionary: FactDictionary,
): JSGraph =
this(dictionary, InMemoryPersisterJS.create())
@JSExport("apply")
def apply(
dictionary: FactDictionary,
persister: Persister,
): JSGraph =
new JSGraph(dictionary, persister)
final class SaveReturnValue(
val valid: Boolean,
val limitViolations: js.Array[LimitViolationWrapper],
) extends js.Object
final class LimitViolationWrapper(
var limitName: String,
var factPath: String,
val level: String,
val limit: String,
val actual: String,
) extends js.Object
object LimitViolationWrapper {
def fromLimitViolation(lv: LimitViolation) =
new LimitViolationWrapper(
lv.limitName,
lv.factPath,
lv.LimitLevel.toString(),
lv.limit,
lv.actual,
)
}
final class PersisterSyncIssueWrapper(
val path: String,
val message: String,
) extends js.Object
object PersisterSyncIssueWrapper {
def fromPersisterSyncIssue(issue: PersisterSyncIssue) =
new PersisterSyncIssueWrapper(
issue.path,
issue.message,
)
}

View file

@ -0,0 +1,36 @@
package gov.irs.factgraph
import gov.irs.factgraph.Meta
import gov.irs.factgraph.types.Enum
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.scalajs.js
import gov.irs.factgraph.definitions.meta.{EnumDeclarationOptionsTrait, EnumDeclarationTrait}
import gov.irs.factgraph.definitions.meta.MetaConfigTrait
final case class JSEnumDeclarationOptions(val value: String) extends EnumDeclarationOptionsTrait
final case class JSEnumDelcaration(
val id: String,
val options: Seq[EnumDeclarationOptionsTrait],
) extends EnumDeclarationTrait
class EnumDeclarationWrapper(val id: String, val options: js.Array[String]) extends js.Object
@JSExportTopLevel("DigestMetaWrapper")
class DigestMetaWrapper(
val version: String,
) extends js.Object:
def toNative(): JSMeta = JSMeta(
version,
)
@JSExportTopLevel("Meta")
class JSMeta(version: String) extends Meta(version):
override def getVersion() =
version
@JSExportTopLevel("MetaFactory")
object JSMeta:
def empty(): JSMeta = new JSMeta("Invalid")
@JSExport
def fromConfig(e: MetaConfigTrait, factDictionary: FactDictionary): Unit =
factDictionary.addMeta(new JSMeta(e.version))

View file

@ -0,0 +1,15 @@
package gov.irs.factgraph.definitions
import gov.irs.factgraph.definitions.fact.FactConfigElement
import gov.irs.factgraph.definitions.meta.MetaConfigTrait
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.scalajs.js
@JSExportTopLevel("FactDictionaryConfig")
object FactDictionaryConfig:
@JSExport
def create(
meta: MetaConfigTrait,
facts: scala.scalajs.js.Array[FactConfigElement],
): FactDictionaryConfigElement =
new FactDictionaryConfigElement(meta, facts.toSeq)

View file

@ -0,0 +1,32 @@
package gov.irs.factgraph.definitions.fact
import gov.irs.factgraph.definitions.fact.{
CommonOptionConfigTraits,
CompNodeConfigElement,
OptionConfigTrait,
OptionConfig,
}
import scala.scalajs.js
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
case class CompNodeConfig(
typeName: String,
children: Iterable[CompNodeConfigTrait],
options: Iterable[OptionConfigTrait],
) extends CompNodeConfigTrait
class CompNodeConfigDigestWrapper(
val typeName: String,
val children: js.Array[CompNodeConfigDigestWrapper],
val options: js.Dictionary[String],
) extends js.Object
object CompNodeDigestWrapper:
def toNative(wrapper: CompNodeConfigDigestWrapper): CompNodeConfig =
new CompNodeConfig(
wrapper.typeName,
wrapper.children.toList.map(CompNodeDigestWrapper.toNative(_)),
wrapper.options.map((key, value) => OptionConfig.create(key, value)),
)

View file

@ -0,0 +1,26 @@
package gov.irs.factgraph.definitions.fact
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, OptionConfigTrait}
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.scalajs.js
case class OptionConfig(name: String, value: String) extends OptionConfigTrait
object OptionConfig {
def create(name: String, value: String): OptionConfig =
OptionConfig(name, value)
def create(
pairs: Iterable[(String, String)],
): scala.scalajs.js.Array[OptionConfig] =
val array = new scala.scalajs.js.Array[OptionConfig](pairs.size)
pairs.map(x => create(x._1, x._2)).foreach(x => array.push(x))
array
def path(path: String): scala.scalajs.js.Array[OptionConfig] =
new scala.scalajs.js.Array[OptionConfig] {
create(CommonOptionConfigTraits.PATH, path)
}
}

View file

@ -0,0 +1,48 @@
package gov.irs.factgraph.definitions.fact
import gov.irs.factgraph.definitions.fact.WritableConfigElement
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.scalajs.js
import gov.irs.factgraph.definitions.fact.{LimitConfigTrait, LimitLevel}
class LimitConfig(
val operation: String,
val level: LimitLevel,
val node: CompNodeConfig,
) extends LimitConfigTrait
class LimitConfigDigestWrapper(
val operation: String,
val level: String,
val node: CompNodeConfigDigestWrapper,
) extends js.Object
class WritableConfigElementDigestWrapper(
val typeName: String,
val options: js.Dictionary[String],
val collectionItemAlias: String | Null,
val limits: js.Array[LimitConfigDigestWrapper],
) extends js.Object
object WritableConfigElementDigestWrapper:
def makeNativeLimit(limitConfig: LimitConfigDigestWrapper): LimitConfigTrait =
val level = LimitLevel.valueOf(limitConfig.level)
new LimitConfig(
limitConfig.operation,
level,
CompNodeDigestWrapper.toNative(limitConfig.node),
)
def toNative(
wrapper: WritableConfigElementDigestWrapper,
): WritableConfigElement =
val collectionItemAlias = wrapper.collectionItemAlias match
case null => None
case _ => Some(wrapper.collectionItemAlias)
WritableConfigElement(
wrapper.typeName,
wrapper.options.map((key, value) => OptionConfig.create(key, value)),
wrapper.limits.map((limitWrapper) => makeNativeLimit(limitWrapper)),
collectionItemAlias,
)

View file

@ -0,0 +1,42 @@
package gov.irs.factgraph.monads
import scala.scalajs.js
import scala.scalajs.js.annotation.JSExport
enum JSEither[+A, +B] {
case Left(v: A)
case Right(v: B)
@JSExport
def left: A | Null = this match
case Left(v) => v
case _ => null
@JSExport
def right: B | Null = this match
case Right(v) => v
case _ => null
@JSExport
def isRight: Boolean = this match
case Right(_) => true
case _ => false
@JSExport
def isLeft: Boolean = !this.isRight
@JSExport
def map[C](f: js.Function1[B, js.Object]): js.Object | Null = this match
case Right(v) => f(v)
case _ => null
@JSExport
def mapLeftRight[C](
lf: js.Function1[A, js.Object],
rf: js.Function1[B, js.Object],
): js.Object = this match
case Left(e) => lf(e)
case Right(v) => rf(v)
// TODO: All of the other useful methods for monads are TK
}

View file

@ -0,0 +1,17 @@
package gov.irs.factgraph.persisters
import gov.irs.factgraph.types.WritableType
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.scalajs.js
@JSExportTopLevel("JSPersister")
object InMemoryPersisterJS:
@JSExport
def create(jsonString: String): InMemoryPersister =
// We take in a json string so that scala.js can deserialize/read it
// into the appropriate TypeContainer classes -- taking in a null-prototype
// js object would not work.
InMemoryPersister.apply(jsonString)
@JSExport
def create(): InMemoryPersister =
InMemoryPersister.apply()

View file

@ -0,0 +1,63 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object AddressFactory:
@JSExportTopLevel("AddressFactory")
def apply(
streetAddress: String,
city: String,
postalCode: String,
stateOrProvence: String,
streetAddressLine2: String = "",
country: String = "United States of America",
): JSEither[AddressValidationFailure, Address] = List(
streetAddress,
streetAddressLine2,
city,
postalCode,
stateOrProvence,
country,
) match
case List(
streetAddress: String,
streetAddressLine2: String,
city: String,
postalCode: String,
stateOrProvence: String,
country: String,
) =>
Try(
new Address(
streetAddress,
city,
postalCode,
stateOrProvence,
streetAddressLine2,
country,
),
) match
case Success(v) => JSEither.Right(v)
case Failure(e: AddressValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
AddressValidationFailure(
"Invalid Address case 1",
None.orNull,
AddressFailureReason.InvalidAddress,
),
)
case _ =>
JSEither.Left(
AddressValidationFailure(
"Invalid Address case 2",
None.orNull,
AddressFailureReason.InvalidAddress,
),
)
@JSExportTopLevel("formatAddressForHTML")
def formatAddressForHTML(addr: Address): String =
addr.toString().replace("\n", "<br />")

View file

@ -0,0 +1,24 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object BankAccountFactory:
@JSExportTopLevel("BankAccountFactory")
def apply(
accountType: String,
routingNumber: String,
accountNumber: String,
): JSEither[BankAccountValidationFailure, BankAccount] =
Try(new BankAccount(accountType, routingNumber, accountNumber)) match
case Success(v) => JSEither.Right(v)
case Failure(e: BankAccountValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
BankAccountValidationFailure(
"Something unexpected went wrong",
None.orNull,
BankAccountFailureReason.InvalidBankAccount,
),
)

View file

@ -0,0 +1,16 @@
package gov.irs.factgraph.types
import scala.scalajs.js
import gov.irs.factgraph.types.Collection
import java.util.UUID
object CollectionFactory:
@js.annotation.JSExportTopLevel("CollectionFactory")
def apply(items: js.Array[String]): Collection =
new Collection(
items.toVector.map(UUID.fromString(_)),
)
@js.annotation.JSExportTopLevel("convertCollectionToArray")
def unapply(c: Collection): js.Array[String] =
import js.JSConverters._
c.items.toJSArray.map(_.toString())

View file

@ -0,0 +1,11 @@
package gov.irs.factgraph.types
import scala.scalajs.js
import gov.irs.factgraph.types.CollectionItem
import java.util.UUID
object CollectionItemFactory:
@js.annotation.JSExportTopLevel("CollectionItemFactory")
def apply(item: String): CollectionItem =
new CollectionItem(
UUID.fromString(item),
)

View file

@ -0,0 +1,83 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.{JSExport, JSExportAll, JSExportTopLevel}
import gov.irs.factgraph.monads.Result
import gov.irs.factgraph.monads.JSEither
import gov.irs.factgraph.validation.{ValidationFailure, ValidationFailureReason}
import gov.irs.factgraph.Graph
@JSExportAll
enum CollectionItemReferenceFailureReason extends ValidationFailureReason:
type UserFriendlyReason = CollectionItemReferenceFailureReason
case InvalidItem
case EmptyCollection
case InvalidCollection
case NotACollection
def toUserFriendlyReason() =
this
@JSExportAll
final case class CollectionItemReferenceFailure(
private val message: String = "",
private val cause: Throwable = None.orNull,
val validationMessage: CollectionItemReferenceFailureReason,
) extends IllegalArgumentException(message, cause),
ValidationFailure[CollectionItemReferenceFailureReason];
object CollectionItemReferenceFactory:
@JSExportTopLevel("CollectionItemReferenceFactory")
def apply(
value: String,
collectionPath: String,
factGraph: Graph,
): JSEither[CollectionItemReferenceFailure, CollectionItem] =
if (value.length > 0) {
factGraph.get(collectionPath) match
case Result.Incomplete =>
JSEither.Left(
CollectionItemReferenceFailure(
"Attempt to reference an empty collection",
None.orNull,
CollectionItemReferenceFailureReason.EmptyCollection,
),
)
case Result.Complete(col: Collection) =>
col.items.map(_.toString()).contains(value) match
case true => JSEither.Right(CollectionItemFactory.apply(value))
case false =>
JSEither.Left(
CollectionItemReferenceFailure(
"Attempt to reference item not in collection",
None.orNull,
CollectionItemReferenceFailureReason.InvalidItem,
),
)
case Result.Placeholder(col: Collection) =>
col.items.map(_.toString()).contains(value) match
case true => JSEither.Right(CollectionItemFactory.apply(value))
case false =>
JSEither.Left(
CollectionItemReferenceFailure(
"Attempt to reference item not in collection",
None.orNull,
CollectionItemReferenceFailureReason.InvalidItem,
),
)
case _ =>
JSEither.Left(
CollectionItemReferenceFailure(
"Attempt to reference a path that isn't a collection",
None.orNull,
CollectionItemReferenceFailureReason.NotACollection,
),
)
} else {
JSEither.Left(
CollectionItemReferenceFailure(
"Blank Collection Item ID",
None.orNull,
CollectionItemReferenceFailureReason.InvalidItem,
),
)
}

View file

@ -0,0 +1,113 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
import scala.scalajs.js.annotation.{JSExport, JSExportAll, JSExportTopLevel}
import java.time.format.DateTimeParseException
import java.lang.{Enum => JavaEnum}
import gov.irs.factgraph.validation.{ValidationFailure, ValidationFailureReason}
import scala.scalajs.js
@JSExportAll
enum DayFailureReason extends JavaEnum[DayFailureReason], ValidationFailureReason:
type UserFriendlyReason = DayFailureReason
case InvalidDayDueToLeapYear
case InvalidDay
case InvalidMonth
case InvalidDate
case ExceedsMaxLimit
case InvalidLimit
def toUserFriendlyReason() =
this // Error reason is already user friendly
@JSExportAll
final case class DayValidationFailure(
private val message: String = "",
private val cause: Throwable = None.orNull,
val validationMessage: DayFailureReason,
) extends IllegalArgumentException(message, cause),
ValidationFailure[DayFailureReason];
object DayFactory:
def checkMax(
inputDay: Day,
max: js.UndefOr[String] = js.undefined,
): JSEither[DayValidationFailure, Day] =
var maxDay = Day(max.toOption)
maxDay match
case Some(v) =>
if (inputDay >= v) {
JSEither.Left(
DayValidationFailure(
"Max date limit exceeded",
None.orNull,
DayFailureReason.ExceedsMaxLimit,
),
)
} else {
JSEither.Right(inputDay)
}
case None if max.isDefined =>
JSEither.Left(
DayValidationFailure(
"Invalid limit set",
None.orNull,
DayFailureReason.InvalidLimit,
),
)
case None =>
JSEither.Right(inputDay)
@JSExportTopLevel("DayFactory")
def apply(
s: String,
max: js.UndefOr[String] = js.undefined,
): JSEither[DayValidationFailure, Day] =
Try(Day(s)) match
case Success(v) =>
checkMax(v, max)
case Failure(e: DateTimeParseException) =>
if (e.getMessage().contains("MonthOfYear"))
JSEither.Left(
DayValidationFailure(
"Invalid Month: out of range (1-12)",
None.orNull,
DayFailureReason.InvalidMonth,
),
)
else if (e.getMessage().contains("DayOfMonth"))
JSEither.Left(
DayValidationFailure(
"Invalid Day: out of range (1-28/31)",
None.orNull,
DayFailureReason.InvalidDay,
),
)
else if (e.getMessage().contains("leap year"))
JSEither.Left(
DayValidationFailure(
"Invalid Day due to leap year",
None.orNull,
DayFailureReason.InvalidDayDueToLeapYear,
),
)
else
JSEither.Left(
DayValidationFailure(
"Invalid Date: invalid day given month",
None.orNull,
DayFailureReason.InvalidDay,
),
)
case _ =>
JSEither.Left(
DayValidationFailure(
"Invalid Date",
None.orNull,
DayFailureReason.InvalidDate,
),
)

View file

@ -0,0 +1,54 @@
package gov.irs.factgraph.types
import java.lang.Enum
import scala.scalajs.js.annotation.{JSExport, JSExportAll, JSExportTopLevel}
import scala.util.matching.Regex
import scala.util.{Try, Success, Failure}
import gov.irs.factgraph.monads.JSEither
import gov.irs.factgraph.validation.{ValidationFailure, ValidationFailureReason}
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.{Try, Success, Failure}
import scala.scalajs.js
object DollarFactory:
@JSExportTopLevel("DollarFactory")
def apply(
value: String,
maxLimit: js.UndefOr[Double] = js.undefined,
minLimit: js.UndefOr[Double] = js.undefined,
): JSEither[DollarValidationFailure, Dollar] =
// maxLimit is not defined or numericValue doesn't exceed the maxlimit
Try(Dollar(value, allowRounding = false)) match
case Success(v) =>
if (maxLimit.isDefined && v > maxLimit.get) {
// value exceeds limit
return JSEither.Left(
DollarValidationFailure(
"Value exceeds max limit",
None.orNull,
DollarFailureReason.ExceedsMaxLimit,
),
)
} else if (minLimit.isDefined && v < minLimit.get) {
// value exceeds limit
return JSEither.Left(
DollarValidationFailure(
"Value exceeds min limit",
None.orNull,
DollarFailureReason.ExceedsMinLimit,
),
)
} else {
JSEither.Right(v)
}
case Failure(e: DollarValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
DollarValidationFailure(
"Failed to parse Dollar",
exception,
DollarFailureReason.InvalidDollar,
),
)

View file

@ -0,0 +1,33 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object EinFactory:
private val EinPattern: Regex = """^([0-9]{2})([0-9]{7})$""".r
@JSExportTopLevel("EinFactory")
def applyEin(
s: String,
): JSEither[EinValidationFailure, Ein] = s match
case EinPattern(prefix, serial) =>
Try(new Ein(prefix, serial)) match
case Success(v) => JSEither.Right(v)
case Failure(e: EinValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
EinValidationFailure(
"Invalid EIN case 1",
None.orNull,
EinFailureReason.InvalidEin,
),
)
case _ =>
JSEither.Left(
EinValidationFailure(
"Invalid EIN case 2",
None.orNull,
EinFailureReason.InvalidEin,
),
)

View file

@ -0,0 +1,56 @@
package gov.irs.factgraph.types
import java.lang.{Enum => JavaEnum}
import scala.scalajs.js.annotation.{JSExport, JSExportAll, JSExportTopLevel}
import scala.util.matching.Regex
import scala.util.{Try, Success, Failure}
import gov.irs.factgraph.monads.JSEither
import gov.irs.factgraph.validation.{ValidationFailure, ValidationFailureReason}
// NOTE: These classes and types are specified just to simplify some front-end logstics
// This pattern may need some reconsideration to minimize boilerplate
@JSExportAll
enum EnumFailureReason extends JavaEnum[EnumFailureReason], ValidationFailureReason:
type UserFriendlyReason = EnumFailureReason
case BlankEnum
case InvalidEnum
def toUserFriendlyReason() =
this // Error reason is already user friendly
@JSExportAll
final case class EnumValidationFailure(
private val message: String = "",
private val cause: Throwable = None.orNull,
val validationMessage: EnumFailureReason,
) extends IllegalArgumentException(message, cause),
ValidationFailure[EnumFailureReason];
object EnumFactory:
@JSExportTopLevel("EnumFactory")
def apply(
value: String,
enumOptionsPath: String,
): JSEither[EnumValidationFailure, Enum] =
if (value.length > 0) {
Try(new Enum(Some(value), enumOptionsPath)) match
case Success(v) => JSEither.Right(v)
case Failure(exception) =>
JSEither.Left(
EnumValidationFailure(
"Failed to initialize enum",
None.orNull,
EnumFailureReason.InvalidEnum,
),
)
} else {
JSEither.Left(
EnumValidationFailure(
"Blank Enum",
None.orNull,
EnumFailureReason.InvalidEnum,
),
)
}

View file

@ -0,0 +1,33 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object IpPinFactory:
private val IpPinPattern: Regex = """^([0-9]{6})$""".r
@JSExportTopLevel("IpPinFactory")
def applyIpPin(
s: String,
): JSEither[IpPinValidationFailure, IpPin] = s match
case IpPinPattern(ippin) =>
Try(new IpPin(ippin)) match
case Success(v) => JSEither.Right(v)
case Failure(e: IpPinValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
IpPinValidationFailure(
"Invalid IP PIN case 1",
None.orNull,
IpPinFailureReason.InvalidIpPin,
),
)
case _ =>
JSEither.Left(
IpPinValidationFailure(
"Invalid IP PIN case 2",
None.orNull,
IpPinFailureReason.InvalidIpPin,
),
)

View file

@ -0,0 +1,46 @@
package gov.irs.factgraph.types
import java.lang.{Enum => JavaEnum}
import scala.scalajs.js.annotation.{JSExport, JSExportAll, JSExportTopLevel}
import scala.util.matching.Regex
import scala.util.{Try, Success, Failure}
import gov.irs.factgraph.monads.JSEither
import gov.irs.factgraph.validation.{ValidationFailure, ValidationFailureReason}
// NOTE: These classes and types are specified just to simplify some front-end logstics
// This pattern may need some reconsideration to minimize boilerplate
@JSExportAll
enum MultiEnumFailureReason extends JavaEnum[MultiEnumFailureReason], ValidationFailureReason:
type UserFriendlyReason = MultiEnumFailureReason
case BlankEnum
case InvalidEnum
def toUserFriendlyReason() =
this // Error reason is already user friendly
@JSExportAll
final case class MultiEnumValidationFailure(
private val message: String = "",
private val cause: Throwable = None.orNull,
val validationMessage: MultiEnumFailureReason,
) extends IllegalArgumentException(message, cause),
ValidationFailure[MultiEnumFailureReason];
object MultiEnumFactory:
@JSExportTopLevel("MultiEnumFactory")
def apply(
value: Set[String],
enumOptionsPath: String,
): JSEither[MultiEnumValidationFailure, MultiEnum] =
Try(new MultiEnum(value, enumOptionsPath)) match
case Success(v) => JSEither.Right(v)
case Failure(exception) =>
JSEither.Left(
MultiEnumValidationFailure(
"Failed to initialize multiEnum",
None.orNull,
MultiEnumFailureReason.InvalidEnum,
),
)

View file

@ -0,0 +1,59 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object PhoneNumberFactory:
private val UsPhonePattern: Regex = """^\+1(\d{3})(\d{3})(\d{4})$""".r
private val E164Pattern: Regex = """^\+([1-9]{1,3})(\d{1,14})$""".r
@JSExportTopLevel("UsPhoneNumberFactory")
def applyUsPhoneNumber(
s: String,
): JSEither[E164NumberValidationFailure, UsPhoneNumber] = s match
case UsPhonePattern(area, office, line) =>
Try(new UsPhoneNumber(area, office, line)) match
case Success(v) => JSEither.Right(v)
case Failure(e: E164NumberValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
E164NumberValidationFailure(
"Malformed US Phone Number",
None.orNull,
E164NumberFailureReason.MalformedPhoneNumber,
),
)
case _ =>
JSEither.Left(
E164NumberValidationFailure(
"Malformed US Phone Number",
None.orNull,
E164NumberFailureReason.MalformedPhoneNumber,
),
)
@JSExportTopLevel("InternationalPhoneNumberFactory")
def applyInternationalPhoneNumber(
s: String,
): JSEither[E164NumberValidationFailure, InternationalPhoneNumber] = s match
case E164Pattern(country, subscriber) =>
Try(new InternationalPhoneNumber(country, subscriber)) match
case Success(v) => JSEither.Right(v)
case Failure(e: E164NumberValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
E164NumberValidationFailure(
"Malformed International Phone Number",
None.orNull,
E164NumberFailureReason.MalformedPhoneNumber,
),
)
case _ =>
JSEither.Left(
E164NumberValidationFailure(
"Malformed International Phone Number",
None.orNull,
E164NumberFailureReason.MalformedPhoneNumber,
),
)

View file

@ -0,0 +1,33 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object PinFactory:
private val PinPattern: Regex = """^([0-9]{5})$""".r
@JSExportTopLevel("PinFactory")
def applyPin(
s: String,
): JSEither[PinValidationFailure, Pin] = s match
case PinPattern(pin) =>
Try(new Pin(pin)) match
case Success(v) => JSEither.Right(v)
case Failure(e: PinValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
PinValidationFailure(
"Invalid PIN case 1",
None.orNull,
PinFailureReason.InvalidPin,
),
)
case _ =>
JSEither.Left(
PinValidationFailure(
"Invalid PIN case 2",
None.orNull,
PinFailureReason.InvalidPin,
),
)

View file

@ -0,0 +1,125 @@
package gov.irs.factgraph.types
import java.lang.{Enum => JavaEnum}
import gov.irs.factgraph.validation.{ValidationFailure, ValidationFailureReason}
import gov.irs.factgraph.monads.JSEither
import scala.scalajs.js.annotation.{JSExport, JSExportAll, JSExportTopLevel}
import scala.scalajs.js
import gov.irs.factgraph.compnodes.{StripChars}
enum StringFailureReason extends JavaEnum[StringFailureReason], ValidationFailureReason:
type UserFriendlyReason = StringFailureReason
case InvalidCharacters
case InvalidEmployerNameLine2Characters
case InvalidEmployerNameLine1Characters
case InvalidCharactersNoNumbers
case InvalidCharactersNumbersOnly
case InvalidForm1099rBox7Codes
case InvalidForm1099rBox11Year
case InvalidMefRatioType
def toUserFriendlyReason() =
this // Error reason is already user friendly
@JSExportAll
final case class StringValidationFailure(
private val message: String = "",
private val cause: Throwable = None.orNull,
val validationMessage: StringFailureReason,
) extends IllegalArgumentException(message, cause),
ValidationFailure[StringFailureReason];
// TODO: This basically replicates the logic of the actual limit processing
// to be able raise the validation on a field. This needs a big refactor
object StringFactory:
val DefaultNamePattern = "[\\sA-Za-z0-9\\-]+"
val DefaultNamePatternNoNumbers = "[\\sA-Za-z\\-]+"
val EmployerNameLine1Pattern =
"(([A-Za-z0-9#\\-\\(\\)]|&|')\\s?)*([A-Za-z0-9#\\-\\(\\)]|&|')"
val EmployerNameLine2Pattern =
"(([A-Za-z0-9#/%\\-\\(\\)]|&|')\\s?)*([A-Za-z0-9#/%\\-\\(\\)]|&|')"
val NumbersOnlyPattern = "[0-9]+"
val Form1099rBox7CodesPattern = "([A-HJ-NP-UWa-hj-np-uw1-9])(?!\\1)[A-HJ-NP-UWa-hj-np-uw1-9]?"
val Form1099rBox11YearPattern = "[1-2][0-9][0-9][0-9]"
val MefRatioTypeAsPercentPattern = "^100(\\.0{1,3})?|[0-9]{1,2}(\\.\\d{1,3})?$"
def checkMatch(
input: String,
maybePattern: js.UndefOr[String] = js.undefined,
): JSEither[StringValidationFailure, String] =
/*
Define a map of patterns to error messages. These were obtained from what was sent to LimitString
on the client side
*/
val patternValidationMappings = Map(
EmployerNameLine2Pattern -> (
"Invalid characters for Employer Name field",
StringFailureReason.InvalidEmployerNameLine2Characters,
),
EmployerNameLine1Pattern -> (
"Invalid characters for Employer Name field",
StringFailureReason.InvalidEmployerNameLine1Characters,
),
DefaultNamePattern -> (
"Invalid characters",
StringFailureReason.InvalidCharacters,
),
DefaultNamePatternNoNumbers -> (
"Invalid characters",
StringFailureReason.InvalidCharactersNoNumbers,
),
NumbersOnlyPattern -> (
"Invalid characters, numbers only",
StringFailureReason.InvalidCharactersNumbersOnly,
),
Form1099rBox7CodesPattern -> (
"Invalid characters, only form 1099-R box 7 codes",
StringFailureReason.InvalidForm1099rBox7Codes,
),
Form1099rBox11YearPattern -> (
"Invalid characters, only valid 4-digit year",
StringFailureReason.InvalidForm1099rBox11Year,
),
MefRatioTypeAsPercentPattern -> (
"Invalid characters for pecentage field",
StringFailureReason.InvalidMefRatioType,
),
)
maybePattern.toOption match
case Some(pattern) =>
if (pattern.r.matches(input)) {
JSEither.Right(input)
} else {
patternValidationMappings.get(pattern) match {
case Some((errorMessage, failureReason)) =>
JSEither.Left(
StringValidationFailure(
errorMessage,
None.orNull,
failureReason,
),
)
case None =>
// Default error message and failure reason if pattern not found
JSEither.Left(
StringValidationFailure(
"Invalid characters",
None.orNull,
StringFailureReason.InvalidCharacters,
),
)
}
}
case None => JSEither.Right(input)
@JSExportTopLevel("StringFactory")
def apply(
s: String,
pattern: js.UndefOr[String] = js.undefined,
): JSEither[StringValidationFailure, String] =
checkMatch(s, pattern)
@JSExportTopLevel("stripDisallowedCharacters")
def stripDisallowedCharacters(input: String, allow: String): String =
StripChars.strip(input, allow)

View file

@ -0,0 +1,34 @@
package gov.irs.factgraph.types
import scala.scalajs.js.annotation.JSExportTopLevel
import scala.util.matching.Regex
import gov.irs.factgraph.monads.JSEither
import scala.util.{Try, Success, Failure}
object TinFactory:
private val TinPattern: Regex = """^([0-9]{3})([0-9]{2})([0-9]{4})$""".r
@JSExportTopLevel("TinFactory")
def applyTin(
s: String,
allowAllZeros: Boolean = false,
): JSEither[TinValidationFailure, Tin] = s match
case TinPattern(area, group, serial) =>
Try(new Tin(area, group, serial, allowAllZeros)) match
case Success(v) => JSEither.Right(v)
case Failure(e: TinValidationFailure) => JSEither.Left(e)
case Failure(exception) =>
JSEither.Left(
TinValidationFailure(
"Invalid TIN case 1",
None.orNull,
TinFailureReason.InvalidTin,
),
)
case _ =>
JSEither.Left(
TinValidationFailure(
"Invalid TIN case 2",
None.orNull,
TinFailureReason.InvalidTin,
),
)

View file

@ -0,0 +1,38 @@
package gov.irs.factgraph.utils
import scala.scalajs.js
import scala.scalajs.js.annotation.JSExportTopLevel
@JSExportTopLevel("unwrapScalaOptional")
def unwrapScalaOptional[T](opt: Option[T]) =
opt match
case Some(value) => value
case _ => null
@JSExportTopLevel("scalaListToJsArray")
def scalaSeqToJsArray[T](seq: List[T]): js.Array[T] =
import js.JSConverters._
seq.toJSArray
@JSExportTopLevel("scalaMapToJsMap")
def scalaMapToJsMap[K, V](map: collection.Map[K, V]): js.Map[K, V] =
import js.JSConverters._
map.toJSMap
@JSExportTopLevel("jsArrayToScalaList")
def jsArrayToScalaList[T](seq: js.Array[T]): List[T] =
import js.JSConverters._
seq.toList
import js.JSConverters._
seq.toList
@JSExportTopLevel("jsSetToScalaSet")
def jsSetToScalaSet[T](seq: js.Set[T]): Set[T] =
import js.JSConverters._
seq.toSet
@JSExportTopLevel("scalaSetToJsSet")
def scalaSetToJsSet[T](seq: Set[T]): js.Set[T] =
import js.JSConverters._
seq.toJSSet

View file

@ -0,0 +1,32 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.definitions.fact.CompNodeConfigElement
import gov.irs.factgraph.monads.Result
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.funsuite.AnyFunSuite
class SwitchSpec extends AnyFunSpec:
describe("Switch") {
it("cannot be empty") {
assertThrows[IllegalArgumentException] {
CompNode.fromDerivedConfig(new CompNodeConfigElement("Switch"))
}
}
it("must contain a boolean condition") {
// the behavior present in this test will crash the js application.
// it is potentially possible to correct this, but the performance hit
// and file size increase could be massive.
// For more, see the Undefined Behaviors section
// https://www.scala-js.org/doc/semantics.html
}
it("all results must be of the same type") {
// the behavior present in this test will crash the js application.
// it is potentially possible to correct this, but the performance hit
// and file size increase could be massive.
// For more, see the Undefined Behaviors section
// https://www.scala-js.org/doc/semantics.html
}
}

View file

@ -0,0 +1,30 @@
package gov.irs.factgraph.types
import org.scalatest.funspec.AnyFunSpec
class DayFactorySpec extends AnyFunSpec:
describe("Day with no limits") {
assert(DayFactory("2012-10-10").right.toString === "2012-10-10")
}
describe("Day with limit not exceeded") {
assert(
DayFactory("2010-10-10", "2010-10-11").right.toString === "2010-10-10"
)
}
describe("Day with limit exceeded") {
val errorReason = DayFactory(
"2010-10-10",
"2010-10-10"
).left.validationMessage.toUserFriendlyReason().toString()
assert(errorReason === "ExceedsMaxLimit")
}
describe("Day with invalid limit set") {
val errorReason = DayFactory(
"2010-10-10",
"2010/10/11"
).left.validationMessage.toUserFriendlyReason().toString()
assert(errorReason === "InvalidLimit")
}

View file

@ -0,0 +1,78 @@
package gov.irs.factgraph.types
import org.scalatest.funspec.AnyFunSpec
class DollarFactorySpec extends AnyFunSpec:
describe("Dollar with no limits") {
assert(DollarFactory.apply("301").right === 301.00)
}
describe("Dollar with limit not exceeded") {
assert(DollarFactory.apply("299", 300).right === 299.00)
}
describe("Dollar with max limit exceeded") {
assert(
DollarFactory
.apply("301", 300)
.left
.validationMessage
.toUserFriendlyReason()
.toString() === "ExceedsMaxLimit"
)
}
describe("Dollar with min limit exceeded") {
assert(
DollarFactory
.apply("10", 300, 100)
.left
.validationMessage
.toUserFriendlyReason()
.toString() === "ExceedsMinLimit"
)
}
describe("Dollar with min limit exceeded with decimals") {
assert(
DollarFactory
.apply("-10.00", 300, 0)
.left
.validationMessage
.toUserFriendlyReason()
.toString() === "ExceedsMinLimit"
)
}
describe("Dollar with limit exceeded and non integer string value") {
assert(
DollarFactory
.apply("test", 300)
.left
.validationMessage
.toUserFriendlyReason()
.toString() === "InvalidCharacters"
)
}
describe("Dollar with decimal Min limit exceeded") {
assert(
DollarFactory
.apply("101.23", 200.00, 101.25)
.left
.validationMessage
.toUserFriendlyReason()
.toString() === "ExceedsMinLimit"
)
}
describe("Dollar with decimal Max limit exceeded") {
assert(
DollarFactory
.apply("84.45", 84.43)
.left
.validationMessage
.toUserFriendlyReason()
.toString() === "ExceedsMaxLimit"
)
}

View file

@ -0,0 +1,309 @@
package gov.irs.factgraph.types
import org.scalatest.funspec.AnyFunSpec
import scala.collection.Factory.StringFactory
class StringFactorySpec extends AnyFunSpec:
describe("String Factory with no Match limit") {
it("one word") {
assert(StringFactory.apply("Firstname").right === "Firstname")
}
it("multiple words") {
assert(StringFactory.apply("Company Name").right === "Company Name")
}
}
describe("String Factory with DefaultNamePattern Match limit") {
it("one word with no invalid characters") {
assert(
StringFactory
.apply("Firstname", StringFactory.DefaultNamePattern)
.right === "Firstname"
)
}
it("multiple words with no invalid characters") {
assert(
StringFactory
.apply("Company Name", StringFactory.DefaultNamePattern)
.right === "Company Name"
)
}
it("one word with invalid characters") {
val errorReason = StringFactory
.apply("Firstname$", StringFactory.DefaultNamePattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidCharacters")
}
it("multiple words with invalid characters") {
val errorReason = StringFactory
.apply("Firstname$ Lastname!", StringFactory.DefaultNamePattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidCharacters")
}
}
describe("String Factory with EmployerNameLine1Pattern Match limit") {
it("one word with no invalid characters") {
assert(
StringFactory
.apply("Firstname", StringFactory.EmployerNameLine1Pattern)
.right === "Firstname"
)
}
it("multiple words with no invalid characters - test &") {
assert(
StringFactory
.apply("Company & Name", StringFactory.EmployerNameLine1Pattern)
.right === "Company & Name"
)
}
it("multiple words with no invalid characters - test '") {
assert(
StringFactory
.apply("Matt's & Son", StringFactory.EmployerNameLine1Pattern)
.right === "Matt's & Son"
)
}
it("multiple words with no invalid characters - test /, ', and & ") {
assert(
StringFactory
.apply("Matt's & Son", StringFactory.EmployerNameLine1Pattern)
.right === "Matt's & Son"
)
}
it("one word with invalid characters") {
val errorReason = StringFactory
.apply("Son$", StringFactory.EmployerNameLine1Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine1Characters")
}
it("multiple words with invalid characters, !") {
val errorReason = StringFactory
.apply("c/o Matt's & Son!", StringFactory.EmployerNameLine1Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine1Characters")
}
it("multiple words with invalid characters, $") {
val errorReason = StringFactory
.apply("c/o Matt's $ Son", StringFactory.EmployerNameLine1Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine1Characters")
}
it("multiple words with two spaces characters") {
val errorReason = StringFactory
.apply("Firstname Lastname", StringFactory.EmployerNameLine1Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine1Characters")
}
}
describe("String Factory with EmployerNameLine2Pattern Match limit") {
it("one word with no invalid characters") {
assert(
StringFactory
.apply("Firstname", StringFactory.EmployerNameLine2Pattern)
.right === "Firstname"
)
}
it("multiple words with no invalid characters - test &") {
assert(
StringFactory
.apply("Company & Name", StringFactory.EmployerNameLine2Pattern)
.right === "Company & Name"
)
}
it("multiple words with no invalid characters - test '") {
assert(
StringFactory
.apply("Matt's & Son", StringFactory.EmployerNameLine2Pattern)
.right === "Matt's & Son"
)
}
it("multiple words with no invalid characters - test /, ', and & ") {
assert(
StringFactory
.apply("c/o Matt's & Son", StringFactory.EmployerNameLine2Pattern)
.right === "c/o Matt's & Son"
)
}
it("multiple words with no invalid characters - test /, ', %, and & ") {
assert(
StringFactory
.apply(
"c/o Matt's & Son % Junior",
StringFactory.EmployerNameLine2Pattern
)
.right === "c/o Matt's & Son % Junior"
)
}
it("one word with invalid characters") {
val errorReason = StringFactory
.apply("Son$", StringFactory.EmployerNameLine2Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine2Characters")
}
it("multiple words with invalid characters, !") {
val errorReason = StringFactory
.apply("c/o Matt's & Son!", StringFactory.EmployerNameLine2Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine2Characters")
}
it("multiple words with invalid characters, $") {
val errorReason = StringFactory
.apply("c/o Matt's $ Son", StringFactory.EmployerNameLine2Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine2Characters")
}
it("multiple words with two spaces characters") {
val errorReason = StringFactory
.apply("Firstname Lastname", StringFactory.EmployerNameLine2Pattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidEmployerNameLine2Characters")
}
}
describe("String Factory with Match limit not defined") {
it("one word with no invalid characters") {
assert(
StringFactory
.apply("Firstname", "[\\sA-Za-z0-9]+")
.right === "Firstname"
)
}
it("multiple words with no invalid characters") {
assert(
StringFactory
.apply("Company Name", "[\\sA-Za-z0-9]+")
.right === "Company Name"
)
}
it("one word with invalid characters") {
val errorReason = StringFactory
.apply("First%&'name$", "[\\sA-Za-z0-9]+")
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidCharacters")
}
it("multiple words with invalid characters") {
val errorReason = StringFactory
.apply("Firstname$ Lastname!", "[\\sA-Za-z0-9]+")
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidCharacters")
}
}
describe("String Factory with Numbers only Match Limit") {
it("one word with no invalid characters") {
assert(
StringFactory
.apply("123", StringFactory.NumbersOnlyPattern)
.right === "123"
)
}
it("one word with invalid characters") {
val errorReason = StringFactory
.apply("123F", StringFactory.NumbersOnlyPattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidCharactersNumbersOnly")
}
it("multiple words with invalid characters") {
val errorReason = StringFactory
.apply("123 4", StringFactory.NumbersOnlyPattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidCharactersNumbersOnly")
}
}
describe("String Factory with Invalid Form 1099R Year Match Limit") {
it("one word with no invalid characters") {
assert(
StringFactory
.apply("1234", StringFactory.Form1099rBox11YearPattern)
.right === "1234"
)
}
it("one word with invalid characters") {
val errorReason = StringFactory
.apply("123F", StringFactory.Form1099rBox11YearPattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidForm1099rBox11Year")
}
it("multiple words with invalid characters") {
val errorReason = StringFactory
.apply("1234 F", StringFactory.Form1099rBox11YearPattern)
.left
.validationMessage
.toUserFriendlyReason()
.toString()
assert(errorReason === "InvalidForm1099rBox11Year")
}
}
describe("String Factory with StripChars") {
it("returns a string without an invalid character") {
val stripCharsResult = StringFactory
.stripDisallowedCharacters("test$", "A-Za-z")
assert(
stripCharsResult === "test"
)
}
it("returns the same string if it doesn't have invalid characters") {
val stripCharsResult = StringFactory
.stripDisallowedCharacters("test", "A-Za-z")
assert(
stripCharsResult === "test"
)
}
it("empty string returns an empty string") {
val stripCharsResult = StringFactory
.stripDisallowedCharacters("", "A-Za-z")
assert(
stripCharsResult === ""
)
}
}

View file

@ -0,0 +1,12 @@
package gov.irs.factgraph.persisters
import gov.irs.factgraph.types.WritableType
import scala.jdk.CollectionConverters.MapHasAsScala
object InMemoryPersisterJava:
def create(store: java.util.Map[String, WritableType]): InMemoryPersister =
new InMemoryPersister(
store.asScala.map((x, y) => (gov.irs.factgraph.Path.apply(x), y)).toMap,
)
def create(): InMemoryPersister =
InMemoryPersister.apply()

View file

@ -0,0 +1,166 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.definitions.fact.{
CommonOptionConfigTraits,
CompNodeConfigElement
}
import gov.irs.factgraph.monads.Result
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.funsuite.AnyFunSuite
import gov.irs.factgraph.definitions.fact.FactConfigElement
import gov.irs.factgraph.FactDefinition
import gov.irs.factgraph.Graph
import gov.irs.factgraph.Path
class SwitchSpec extends AnyFunSpec:
describe("Switch") {
it("cannot be empty") {
assertThrows[IllegalArgumentException] {
CompNode.fromDerivedConfig(new CompNodeConfigElement("Switch"))
}
}
it("must contain a boolean condition") {
assertThrows[UnsupportedOperationException] {
CompNode.fromDerivedConfig(
new CompNodeConfigElement(
"Switch",
Seq(
new CompNodeConfigElement(
"Case",
Seq(
new CompNodeConfigElement(
"When",
Seq(
CompNodeConfigElement(
"String",
Seq.empty,
CommonOptionConfigTraits.value("Hello")
)
)
),
new CompNodeConfigElement(
"Then",
Seq(
CompNodeConfigElement(
"String",
Seq.empty,
CommonOptionConfigTraits.value("World")
)
)
)
)
)
)
)
)
}
}
it("can switch on a dependency") {
val dictionary = FactDictionary.apply()
val predicateConfig = FactConfigElement(
"/predicate",
None,
Some(new CompNodeConfigElement("True")),
None
)
FactDefinition.fromConfig(predicateConfig)(using dictionary)
val switchConfig = FactConfigElement(
"/test-switch",
None,
Some(
new CompNodeConfigElement(
"Switch",
Seq(
new CompNodeConfigElement(
"Case",
Seq(
new CompNodeConfigElement(
"When",
Seq(
new CompNodeConfigElement(
"Dependency",
Seq.empty,
CommonOptionConfigTraits.path("/predicate")
)
)
),
new CompNodeConfigElement(
"Then",
Seq(
CompNodeConfigElement(
"String",
Seq.empty,
CommonOptionConfigTraits.value("World")
)
)
)
)
)
)
)
),
None
)
FactDefinition.fromConfig(switchConfig)(using dictionary)
val graph = Graph(dictionary)
assert(graph.get("/predicate") == Result.Complete(true))
val thens = graph.get("/test-switch")
assert(thens == Result.Complete("World"))
}
it("all results must be of the same type") {
assertThrows[UnsupportedOperationException] {
CompNode.fromDerivedConfig(
new CompNodeConfigElement(
"Switch",
Seq(
new CompNodeConfigElement(
"Case",
Seq(
new CompNodeConfigElement(
"When",
Seq(new CompNodeConfigElement("False"))
),
new CompNodeConfigElement(
"Then",
Seq(
CompNodeConfigElement(
"Int",
Seq.empty,
CommonOptionConfigTraits.value("1")
)
)
)
)
),
new CompNodeConfigElement(
"Case",
Seq(
new CompNodeConfigElement(
"When",
Seq(new CompNodeConfigElement("True"))
),
new CompNodeConfigElement(
"Then",
Seq(
CompNodeConfigElement(
"Dollar",
Seq.empty,
CommonOptionConfigTraits.value("2.00")
)
)
)
)
)
)
)
)
}
}
}

View file

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is a manual hack because the sbt-bom generator that we use
in scala seems to be missing:
1. dependencies added via plugin and
2. dependencies that come in as `provided`, even when given the
appropriate flag.
While I'd love to fix that generator, I'd moreso love to get an
sbom to the IRS. So I'm writing this hacky sbom that we'll merge
with the automatically output sbom.
I've gathered the transitive dependencies by going through the
maven central repository on sonatype.
-->
<bom version="1" xmlns="http://cyclonedx.org/schema/bom/1.0">
<components>
<component type="library">
<group>org.scala-js</group>
<name>scalajs-stubs_3</name>
<version>1.1.0</version>
<scope>required</scope>
<licenses>
<license>
<name>BSD New</name>
</license>
</licenses>
<purl>pkg:maven/org.scala-js/scalajs-stubs_3@1.1.0</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.scala-js</group>
<name>sbt-scalajs</name>
<version>1.13.0</version>
<scope>required</scope>
<licenses>
<license>
<name>Apache-2.0</name>
</license>
</licenses>
<purl>pkg:maven/org.scala-js/sbt-scalajs@1.13.0</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.scala-js</group>
<name>scalajs-env-nodejs_2.12</name>
<version>1.4.0</version>
<scope>required</scope>
<licenses>
<license>
<name>Apache-2.0</name>
</license>
</licenses>
<purl>pkg:maven/org.scala-js/scalajs-env-nodejs_2.12@1.4.0</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.scala-js</group>
<name>scalajs-js-envs_2.12</name>
<version>1.4.0</version>
<scope>required</scope>
<licenses>
<license>
<name>Apache-2.0</name>
</license>
</licenses>
<purl>pkg:maven/org.scala-js/scalajs-js-envs_2.12@1.4.0</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.scala-js</group>
<name>scalajs-linker-interface_2.12</name>
<version>1.13.2</version>
<scope>required</scope>
<licenses>
<license>
<name>Apache-2.0</name>
</license>
</licenses>
<purl>pkg:maven/org.scala-js/scalajs-linker-interface_2.12@1.13.2</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.scala-js</group>
<name>scalajs-sbt-test-adapter_2.12</name>
<version>1.13.2</version>
<scope>required</scope>
<licenses>
<license>
<name>Apache-2.0</name>
</license>
</licenses>
<purl>pkg:maven/org.scala-js/scalajs-sbt-test-adapter_2.12@1.13.2</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.scoverage</group>
<name>sbt-scoverage</name>
<version>2.0.5</version>
<scope>required</scope>
<licenses>
<license>
<name>Apache-2.0</name>
</license>
</licenses>
<purl>pkg:maven/org.scoverage/sbt-scoverage@2.0.5</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.portable-scala</group>
<name>sbt-scalajs-crossproject</name>
<version>1.2.0</version>
<scope>required</scope>
<licenses>
<license>
<name>BSD3</name>
</license>
</licenses>
<purl>pkg:maven/org.portable-scala/sbt-scalajs-crossproject@1.2.0</purl>
<modified>false</modified>
</component>
<component type="library">
<group>org.portable-scala</group>
<name>sbt-platform-deps</name>
<version>1.0.0</version>
<scope>required</scope>
<licenses>
<license>
<name>BSD3</name>
</license>
</licenses>
<purl>pkg:maven/org.portable-scala/sbt-platform-deps@1.0.0</purl>
<modified>false</modified>
</component>
</components>
</bom>

View file

@ -0,0 +1 @@
sbt.version=1.9.2

View file

@ -0,0 +1,9 @@
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.5")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.2")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")
// This plugin is currently the only sbom builder for scala
// but even then, it looks like it has limited usage.
// If this plugin ever gives us issues, we should
// be open to re-evaluating it.
addSbtPlugin("io.github.siculo" %% "sbt-bom" % "0.3.0")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")

View file

@ -0,0 +1,94 @@
import gov.irs.factgraph.{FactDictionary, Graph}
import gov.irs.factgraph.persisters.InMemoryPersister
import gov.irs.factgraph.types.Dollar
// ###############################
// ## 1. What's a Fact Graph? ##
// ###############################
//
// Take a look at [Form 1040][1]. Line 12c asks the taxpayer to add lines 12a
// and 12b. This is a common pattern on tax forms.
//
// [1]: https://www.irs.gov/pub/irs-pdf/f1040.pdf
//
// There is a relationship betwen lines 12a, 12b, and 12c. In order to know what
// should be entered in 12c, we need to first know the values of 12a and 12b.
//
// This pattern might remind you of a spreadsheet. If we were to transcribe Form
// 1040 into Excel, we might imagine that line 12c would be a formula like
// =SUM(Line12A,Line12B). Excel spreadsheets are _declarative_, which is to say
// we define the relationships between cells using formulas, but we leave it up
// to Excel to determine how to solve the spreadsheet.
//
// Excel models the relationships between cells as a _directed acyclic graph_
// (DAG). "Directed" means that the relationships are one way: the fact that
// line 12c references line 12a does not affect the value 12a. "Acyclic" means
// that these references can never form a loop: if the value of line 12a were to
// depend on line 12c while 12c depends on 12a, Excel wouldn't know where to
// start solving the spreadsheet; indeed, it would show an error.
//
// A Fact Graph is a declarative way of applying a complex set of rules, like
// tax law, to partial information entered by a user. A Fact Graph is a DAG,
// just like Excel. Instead of calling the vertices of the DAG "cells," we'll
// call them "facts."
//
// Let's take the example of lines 12ac from Form 1040. First, we'll create a
// FactDictionary to define our facts.
val dictionary = FactDictionary.fromXml(
<Dictionary>
<Fact path="/line12a">
<Writable><Dollar /></Writable>
</Fact>
<Fact path="/line12b">
<Writable><Dollar /></Writable>
</Fact>
<Fact path="/line12c">
<Derived>
<Add>
<Dependency path="../line12a" />
<Dependency path="../line12b" />
</Add>
</Derived>
</Fact>
</Dictionary>
)
// Lines 12a and 12b are Writable Facts. Each of these Facts stores a
// user-entered value, in this case a Dollar amount.
//
// Line 12c is a Derived Fact. It adds lines 12a and 12b. As you might expect,
// it also holds a Dollar amount; we don't need to specify that it is a Dollar
// because the Fact Graph infers this from the definition.
//
// Let's hook up this FactDictionary in a new Graph.
val graph = Graph(
dictionary,
InMemoryPersister(
"/line12a" -> Dollar("12550.00"),
"/line12b" -> Dollar("300.00")
)
)
// We've provided the Graph with a Persister seeded with values for lines 12a
// and 12b. As a result, we're all set to ask our Graph for an answer to the
// value of line 12c.
graph.get("/line12c")
// Voila! Our first Fact Graph!
//
// Okay, so this is pretty basic. But from Facts just like this, we'll be able
// to represent a system as complex as the Internal Revenue Code.
//
// (It's also worth noting that a real Fact Graph wouldn't ask the user to
// specify the value of line 12a, for example. 12a would itself be a Derived
// Fact, drawing from the taxpayer's filing status, age, etc. A real Fact Graph
// would describe a quite deeply nested tree of Facts.)
//
// You may notice that the value of line 12c is wrapped in something called a
// Result, and it's marked as "complete." This is a key concept of the Fact
// Graph, as we'll explore in the next chapter.

View file

@ -0,0 +1,185 @@
import gov.irs.factgraph.{FactDictionary, Graph}
import gov.irs.factgraph.persisters.InMemoryPersister
// #######################
// ## 2. Completeness ##
// #######################
//
// Let's create another FactDictionary.
val dictionary = FactDictionary.fromXml(
<Dictionary>
<Fact path="/factA">
<Writable><Boolean /></Writable>
</Fact>
<Fact path="/factB">
<Writable><Boolean /></Writable>
</Fact>
<Fact path="/factC">
<Writable><Boolean /></Writable>
</Fact>
<Fact path="/conclusion">
<Derived>
<All>
<Dependency path="../factA" />
<Dependency path="../factB" />
<Dependency path="../factC" />
</All>
</Derived>
</Fact>
</Dictionary>
)
// Instead of using Dollar amounts, our Facts are now Boolean (true/false)
// values. And instead of our Derived Fact performing an Add operation, it now
// performs an All. As you might expect, the conclusion is true if all of facts
// AC are true.
val graph = Graph(
dictionary,
InMemoryPersister(
"/factA" -> true,
"/factB" -> true,
"/factC" -> false
)
)
graph.get("/conclusion")
// If you're familiar with tax concepts, you probably have some ideas for how
// the All operation might be useful to us. For example, in order to claim a
// child as a dependent, they must pass six tests. In other words, if six Facts
// are all true, a Fact representing the dependent's claimability is also true.
//
// In the previous chapter, we described a Fact Graph as a declarative way of
// applying complex rules to _partial_ information entered by a user. Thus far,
// we have only looked at Fact Graphs where all of the Facts are known. Let's
// change that.
graph.delete("/factC")
graph.save()
graph.get("/factC")
// Pop quiz: now that fact C is incomplete, what should the value of the
// conclusion be?
//
// Hint: there are no wrong answers.
//
// You might argue that the conclusion should be false. We said above that the
// conclusion is true if all of its children are true, and fact C is missing,
// not true; therefore not all of the children are true, and the conclusion
// should be false.
//
// You might argue that the conclusion should be incomplete. The problem is our
// lack of omniscience. Fact C represents a truth observable in the real world,
// and thus the real value of the conclusion is knowable; it's just not known to
// us. Until we have full knowledge, we should report that we don't know.
//
// You might even argue that the conclusion should be true, given that at least
// the set of facts that we know thus far are all true.
//
// All of these possibilities are valid. Indeed, as a taxpayer completes a tax
// return, we'll want the All operation to adopt each of these behaviors in
// various situations.
//
// For example, we might imagine presenting the taxpayer with a running tally of
// refund owed or balance due. There will be a set of tax credits for which we
// have not yet determined the taxpayer's eligibility. In this case, it would be
// far better to assume the taxpayer is ineligible until we can conclusively
// prove their eligibility, rather than presuming eligilibity and taking credits
// away from the taxpayer one by one.
//
// Conversely, we might imagine a Fact that represents whether the taxpayer's
// situation is in scope for the product. Here, we want to presume exactly the
// opposite, that the taxpayer is indeed eligible to use the product until we
// identify some circumstance that the tool does not support.
//
// This ambiguity is a signal that we have not fully understood the problem at
// hand. A Fact Graph is not just calculating values; it is also calculating
// _completeness_. Let's check the value of our conclusion.
graph.get("/conclusion")
// As you can see, we default to reporting that the value of the result is
// missing ("???"). This result is also, as should be expected, marked as
// incomplete. But because we have separated these concepts of value and
// completeness, we could have instead defined the Fact as:
<Fact path="/conclusion">
<Derived>
<All>
<Dependency path="../factA" />
<Dependency path="../factB" />
<Dependency path="../factC" />
</All>
</Derived>
<Placeholder><False /></Placeholder>
</Fact>
// Note the addition of a Placeholder tag. If you like, you can update the
// FactDictionary with this revised definition. If you do, we'll see that the
// result of the conclusion has changed:
// graph.get("/conclusion") // : Result[Any] = Result(false, incomplete)
// The conclusion is still incomplete, but instead of "???," it now has the
// value of false. This would allow us to calculate other Facts using this
// value, although those Facts would also be marked as incomplete if they rely
// on the placeholder value.
//
// Let's try one final example to bring it home.
graph.set("/factB", false)
graph.save()
// Keeping in mind that fact C is still incomplete, what would we expect the
// result of the conclusion to be? Let's check it out.
graph.get("/conclusion")
// Unsuprisingly, the value is false. But more significantly, despite fact C
// still being missing, the result is now complete. This makes sense if we think
// about whether the value of fact C could affect the result. Now that fact B is
// false, the value of fact C no longer matters; no matter what, the children of
// the All operation cannot all be true.
//
// We can even take it one step further and erase fact A as well.
graph.delete("/factA")
graph.save()
graph.get("/conclusion")
// It doesn't matter! Take a moment and consider how this might be useful in the
// context of a tax return.
//
// There is a vast quantity of information that could potentially affect an
// individual's taxes. An effective tax filing product will need to be able to
// determine what information is relevant, and what is not. We will want to only
// ask questions that are relevant, that is to say, that could affect your taxes
// one way or another. The moment that we knew that fact B was false, facts A
// and C ceased to have any relevance, at least to our conclusion fact.
//
// The concepts of relevance and completeness are inextricable. You might
// imagine an interface where each question contains a set of rules to
// conditionally select the next question to present to the user. But this
// interface would be incredibly fragile and difficult to maintain. And it would
// risk the user reaching a state where they have not been asked for information
// that is necessary to finish their tax return.
//
// On the other hand, imagine an interface that looks to the Fact Graph to
// determine the relevance of a question. We can describe the goal of asking
// about facts A, B, and C as to inform the conclusion. And if the interface
// ever sees...
graph.get("/conclusion").complete
// ...it knows that it has sufficient information to draw a conclusion, and it
// does't need to ask additional questions.
//
// Now that we've explored boolean logic a bit, the next chapter will dive into
// numbers and numeric operations.

View file

@ -0,0 +1,340 @@
import gov.irs.factgraph.{Factual, FactDictionary, Graph}
import gov.irs.factgraph.compnodes.CompNode
import gov.irs.factgraph.persisters.InMemoryPersister
import gov.irs.factgraph.types.Dollar
// ##################
// ## 3. Numbers ##
// ##################
//
// The core Fact Graph library supports three numeric types.
//
// * Int. A 32-bit signed integer.
// * Rational. The ratio of two integers, i.e. x/y. We will represent whole
// percentages as a Rational with a denominator of 100.
// * Dollar. An opaque wrapper over Java's BigDecimal type, with operations
// rounding to two decimal places using HALF_EVEN mode, i.e. rounding half
// cents to the nearest even cent (also known as banker's rounding).
// Rounding a Dollar amount to a whole number of dollars uses HALF_UP mode,
// where 50 cents is rounded up, per IRS guidance.
//
// Numeric comparisons (GreaterThan, GreaterThanOrEqual, LessThan, and
// LessThanOrEqual) require both left- and right-hand sides to be of the same
// numeric type, as do Equal and NotEqual, as well as the numeric min/max
// operations GreaterOf and LesserOf.
//
// However, the arithmatic operations (Add, Subtract, Multiple, and Divide) can
// take any combination of numeric types. The type of the output can be found
// using the following tables:
//
// When adding, subtracting, or multiplying:
//
// | || Int | Rational | Dollar |
// | -------- || ----------- | ----------- | ------ |
// | Int || Int | Rational[1] | Dollar |
// | Rational || Rational[1] | Rational | Dollar |
// | Dollar || Dollar | Dollar | Dollar |
//
// When dividing:
//
// | || Int | Rational | Dollar |
// | -------- || ---------- | -------- | ------ |
// | Int || _Rational_ | Rational | Dollar |
// | Rational || Rational | Rational | Dollar |
// | Dollar || Dollar | Dollar | Dollar |
//
// [1] Adding or subtracting two Rationals with the same denominator will
// preserve the value of the denominator. Other operations will simplify the
// resulting fraction, if possible.
//
// Let's take a look at an example.
given Factual = null
// Note: Because we're using a CompNode outside of a Fact, we need to provide
// this contextual parameter.
CompNode
.fromXml(
<Divide>
<Dividend>
<Int>1</Int>
</Dividend>
<Divisors>
<Int>2</Int>
</Divisors>
</Divide>
)
.get(0)
// As we can see from the above tables, dividing an Int by an Int produces a
// Rational, in this case, 1/2.
//
// While we can only have one Dividend, Divide will take multiple Divisors and
// apply them sequentially.
CompNode
.fromXml(
<Divide>
<Dividend>
<Int>1</Int>
</Dividend>
<Divisors>
<Int>2</Int>
<Int>3</Int>
</Divisors>
</Divide>
)
.get(0)
// Let's explore two additional operations that are useful for taxes.
CompNode
.fromXml(
<StepwiseMultiply>
<Multiplicand>
<Dollar>9999.99</Dollar>
</Multiplicand>
<Rate>
<Rational>50/1000</Rational>
</Rate>
</StepwiseMultiply>
)
.get(0)
// StepwiseMultiply takes two inputs, a Dollar amount and a rate, expressed as a
// Rational. We'll multiply the amount by the rate as follows: the numerator and
// the denominator of the rate are both whole dollars. We'll divide the amount
// by the denominator, dropping any remainder, and then multiply by the
// numerator. So $1,000 fits into $9,999.99 nine times, and nine times $50 is
// $450. Sometimes tax law will prescribe this less precise form of
// multiplication, for example in the phase-outs to the Child Tax Credit.
CompNode
.fromXml(
<Round>
<Dollar>2.50</Dollar>
</Round>
)
.get(0)
// Round, as you would expect, rounds a Dollar amount to the nearest whole
// number of dollars. 50 cents is always rounded up, per IRS guidance.
//
// Let's put it all together by implementing the TY2021 tax tables for a Married
// Filing Jointly couple or Qualifying Widow(er).
val dictionary = FactDictionary.fromXml(
<Dictionary>
<Fact path="/taxableIncome">
<Writable><Dollar /></Writable>
</Fact>
<Fact path="/tax">
<Derived>
<Round>
<Switch>
// Not over $19,900:
// 10% of the taxable income
<Case>
<When>
<LessThanOrEqual>
<Left><Dependency path="../taxableIncome" /></Left>
<Right><Dollar>19900</Dollar></Right>
</LessThanOrEqual>
</When>
<Then>
<Multiply>
<Rational>10/100</Rational>
<Dependency path="../taxableIncome" />
</Multiply>
</Then>
</Case>
// Over $19,900 but not over $81,050:
// $1,990 plus 12% of the excess over $19,900
<Case>
<When>
<LessThanOrEqual>
<Left><Dependency path="../taxableIncome" /></Left>
<Right><Dollar>81050</Dollar></Right>
</LessThanOrEqual>
</When>
<Then>
<Add>
<Dollar>1990</Dollar>
<Multiply>
<Rational>12/100</Rational>
<Subtract>
<Minuend>
<Dependency path="../taxableIncome" />
</Minuend>
<Subtrahends>
<Dollar>19900</Dollar>
</Subtrahends>
</Subtract>
</Multiply>
</Add>
</Then>
</Case>
// Over $81,050 but not over $172,750:
// $9,328 plus 22% of the excess over $81,050
<Case>
<When>
<LessThanOrEqual>
<Left><Dependency path="../taxableIncome" /></Left>
<Right><Dollar>172750</Dollar></Right>
</LessThanOrEqual>
</When>
<Then>
<Add>
<Dollar>9328</Dollar>
<Multiply>
<Rational>22/100</Rational>
<Subtract>
<Minuend>
<Dependency path="../taxableIncome" />
</Minuend>
<Subtrahends>
<Dollar>81050</Dollar>
</Subtrahends>
</Subtract>
</Multiply>
</Add>
</Then>
</Case>
// Over $172,750 but not over $329,850:
// $29,502 plus 24% of the excess over $172,750
<Case>
<When>
<LessThanOrEqual>
<Left><Dependency path="../taxableIncome" /></Left>
<Right><Dollar>329850</Dollar></Right>
</LessThanOrEqual>
</When>
<Then>
<Add>
<Dollar>29502</Dollar>
<Multiply>
<Rational>24/100</Rational>
<Subtract>
<Minuend>
<Dependency path="../taxableIncome" />
</Minuend>
<Subtrahends>
<Dollar>172750</Dollar>
</Subtrahends>
</Subtract>
</Multiply>
</Add>
</Then>
</Case>
// Over $329,850 but not over $418,850:
// $67,206 plus 32% of the excess over $329,850
<Case>
<When>
<LessThanOrEqual>
<Left><Dependency path="../taxableIncome" /></Left>
<Right><Dollar>418850</Dollar></Right>
</LessThanOrEqual>
</When>
<Then>
<Add>
<Dollar>67206</Dollar>
<Multiply>
<Rational>32/100</Rational>
<Subtract>
<Minuend>
<Dependency path="../taxableIncome" />
</Minuend>
<Subtrahends>
<Dollar>329850</Dollar>
</Subtrahends>
</Subtract>
</Multiply>
</Add>
</Then>
</Case>
// Over $418,850 but not over $628,300:
// $95,686 plus 35% of the excess over $418,850
<Case>
<When>
<LessThanOrEqual>
<Left><Dependency path="../taxableIncome" /></Left>
<Right><Dollar>628300</Dollar></Right>
</LessThanOrEqual>
</When>
<Then>
<Add>
<Dollar>95686</Dollar>
<Multiply>
<Rational>35/100</Rational>
<Subtract>
<Minuend>
<Dependency path="../taxableIncome" />
</Minuend>
<Subtrahends>
<Dollar>418850</Dollar>
</Subtrahends>
</Subtract>
</Multiply>
</Add>
</Then>
</Case>
// Over $628,300:
// $168,993.50 plus 37% of the excess over $628,300
<Case>
<When>
<True />
</When>
<Then>
<Add>
<Dollar>168993.50</Dollar>
<Multiply>
<Rational>37/100</Rational>
<Subtract>
<Minuend>
<Dependency path="../taxableIncome" />
</Minuend>
<Subtrahends>
<Dollar>628300</Dollar>
</Subtrahends>
</Subtract>
</Multiply>
</Add>
</Then>
</Case>
</Switch>
</Round>
</Derived>
</Fact>
</Dictionary>
)
val graph = Graph(
dictionary,
InMemoryPersister(
"/taxableIncome" -> Dollar("75000.00")
)
)
graph.get("/tax")
// Note that when the arguments to an operation serve different roles, the
// operations require us to explicilty label them. So while Add and Multiply can
// take arguments in any order, Subtract and Divide use Minuend/Subtrahends and
// Dividend/Divisors to avoid ambiguity. Similarly, comparison operations like
// LessThanOrEqual explicitly specify Left and Right.
//
// As a bonus, we have introduced the Switch statement, which selects the first
// Case where the When condition returns true. All of the Whens must be
// booleans, while the Thens can be of any type, as long as it is the same
// across all Cases.
//
// In the next chapter, we'll look at the powerful concept of Collections, which
// is the final piece we'll need to enable the Fact Graph to model an entire
// tax return.

View file

@ -0,0 +1,202 @@
import gov.irs.factgraph.{Factual, FactDictionary, Graph, Path}
import gov.irs.factgraph.compnodes.CompNode
import gov.irs.factgraph.persisters.InMemoryPersister
import gov.irs.factgraph.types.{Collection, CollectionItem}
import java.util.UUID
// ##################################
// ## 4. Collections and Vectors ##
// ##################################
//
// Taxes frequently feature sets of repeated items. A tax return may include
// multiple filers, dependents, W-2s, 1099s, etc. We will call these sets of
// repeated items Collections. Let's use dependents as an example.
val dictionary = FactDictionary.fromXml(
<Dictionary>
<Fact path="/taxYear">
<Derived><Int>2021</Int></Derived>
</Fact>
<Fact path="/familyAndHousehold">
<Writable><Collection /></Writable>
</Fact>
<Fact path="/familyAndHousehold/*/yearOfBirth">
<Writable><Int /></Writable>
</Fact>
<Fact path="/familyAndHousehold/*/age">
<Derived>
<Subtract>
<Minuend><Dependency path="/taxYear" /></Minuend>
<Subtrahends><Dependency path="../yearOfBirth" /></Subtrahends>
</Subtract>
</Derived>
</Fact>
// We're going to play with this dictionary quite a lot. We'll use CompNodes
// that pretend to be this entryPoint fact in order to avoid needing to
// redefine the dictionary repeatedly.
<Fact path="/entryPoint">
<Derived><True /></Derived>
</Fact>
</Dictionary>
)
val dependentId1 = UUID.randomUUID()
val dependentId2 = UUID.randomUUID()
val dependentId3 = UUID.randomUUID()
val graph = Graph(
dictionary,
InMemoryPersister(
"/familyAndHousehold" -> Collection(
Vector(dependentId1, dependentId2, dependentId3)
),
s"/familyAndHousehold/#$dependentId1/yearOfBirth" -> 2013,
s"/familyAndHousehold/#$dependentId2/yearOfBirth" -> 2016,
s"/familyAndHousehold/#$dependentId3/yearOfBirth" -> 2018
)
)
// A Collection is represented as just another Fact, although it has some
// special properties. Let's take a look.
graph.get("/familyAndHousehold")
// As with any Fact, it returns a Result, reflecting that the Collection could
// be incomplete. The value contains a Vector with three UUIDs, representing
// each of the collection items, in this case, dependents. We can access a
// CollectionItem fact using the UUIDs.
graph.get(s"/familyAndHousehold/#$dependentId1")
// Note that we did not define this child fact; it was created automatically by
// a feature called extracts. Essentially, we know that a Collection fact will
// always have CollectionItem children, so the Fact Graph takes care of them for
// us. Date facts also use this feature to create year, month, and day extracts.
//
// TODO: Date HASN'T BEEN IMPLEMENTED YET
//
// A CollectionItem contains one or more facts, which are repeated across all of
// the items in the Collection.
graph.get(s"/familyAndHousehold/#$dependentId1/age")
// Looking closer at the definition of age, we can see something interesting
// about how it describes its arguments. The dependents' year of birth uses a
// relative path...
<Dependency path="../yearOfBirth" />
// ...indicating that the year of birth is specific to that particular
// dependent. Meanwhile the tax year is an absolute path...
<Dependency path="/taxYear" />
// ...reflecting that the tax year is shared across the entire tax return. We
// could also have written tax year as a relative path like so...
<Dependency path="../../../taxYear" />
// ...but this is a lot more cumbersome and unnecessary when dealing with a
// single fact located at the root of the graph.
//
// We can use a wildcard (*) to get all of the children of a collection at once.
graph.getVect("/familyAndHousehold/*")
// We can also use this to get access all of the dependents' ages.
graph.getVect(s"/familyAndHousehold/*/age")
// Note that instead of using graph.get, we have used graph.getVect. If we look
// at the return type, we'll see that we now have a MaybeVector of Results. A
// MaybeVector can either be Single or Multiple.
//
// If the MaybeVector is Single, it will always contain just one Result. Indeed,
// if you check out the implementation of .get in Graph.scala, you will see that
// .get is just a convenience method that gives us the output of getVect if and
// only if that output is Single.
//
// If the MaybeVector is Multiple, it contains any number of Results, including
// zero. And just like the Result type, a Multiple MaybeVector has completeness,
// reflecting whether the number of items in the Collection could change.
graph.get("/familyAndHousehold").complete
graph.getVect("/familyAndHousehold/*").complete
graph.getVect(s"/familyAndHousehold/*/age").complete
// Because the dependents Collection Fact is marked as complete, all of the
// MaybeVectors of its children are also complete. If the Collection was
// incomplete, say because the taxpayer was still in the process of adding
// dependents, the MaybeVectors would be incomplete, letting us know that they
// might still change.
//
// A Fact can use a wildcard in its definition. This fact will always return
// multiple values:
given Factual = graph(Path("/entryPoint"))(0).get
CompNode
.fromXml(
<Dependency path="/familyAndHousehold/*/age" />
)
.get
// The definition will always be a Multiple, and must be accessed using
// graph.getVect. More often, however, we'll aggregate these multiple values
// using operations like Count and Sum. Here's an example.
CompNode
.fromXml(
<Count>
<LessThanOrEqual>
<Left><Dependency path="/familyAndHousehold/*/age" /></Left>
<Right><Int>6</Int></Right>
</LessThanOrEqual>
</Count>
)
.get
// This fact counts the number of dependents who are six or younger. Now the
// fact returns a Single, reflecting that however many dependents there are,
// they will always be aggregated to a single count.
//
// Note that Single MaybeVectors don't have completeness. This makes sense
// because they will always contain a single Result. But did we lose information
// when aggregating the Multiple ages?
//
// No! If the Multiple MaybeVector had been incomplete, then the aggregated
// Result would have been incomplete too, reflecting that if more dependents are
// added, the count of those six and younger might change.
//
// Also note that before aggregating, we compared the Multiple to the value of
// six. But <Int>6</Int> is Single! How did this work?
//
// From this, we can learn that all Fact Graph operations are _vectorized_. When
// one of the arguments to an operation is Single, and another is Multiple, the
// output will be a Multiple, calculated by applying the Single value to each of
// the Multiple values in turn.
//
// We can even operate on multiple Multiple arguments, provided that they all
// come from the same Collection and thus have the same length. For example,
// take this useless fact that doubles the ages and adds one.
CompNode
.fromXml(
<Add>
<Dependency path="/familyAndHousehold/*/age" />
<Dependency path="/familyAndHousehold/*/age" />
<Int>1</Int>
</Add>
)
.get
// As long as all of the arguments are either Multiples of the same length or
// Single, we can go wild combining them and let the Fact Graph do its thing.
//
// So if Collections and CollectionItems are Facts, then does that mean we could
// operate on them like any other Facts? In short, yes, and the possibilities
// that arise are described in the next chapter.

View file

@ -0,0 +1,164 @@
import gov.irs.factgraph.{FactDictionary, Graph}
import gov.irs.factgraph.persisters.InMemoryPersister
import gov.irs.factgraph.types.{Collection, CollectionItem, Dollar}
import java.util.UUID
// ###################################################
// ## 5. Aliased Collections and Collection Items ##
// ###################################################
//
// In the last chapter, we described Collections and CollectionItems as Facts
// with special properties. In this chapter, we'll explore some more of those
// special proprties, and also look at how we can operate on Collections and
// CollectionItems same as we would any other Fact.
//
// As we often do, let's start by building a FactDictionary with some examples.
val dictionary = FactDictionary.fromXml(
<Dictionary>
<Fact path="/filers">
<Writable><Collection /></Writable>
</Fact>
<Fact path="/filers/*/firstName">
<Writable><String /></Writable>
</Fact>
<Fact path="/filers/*/isPrimaryFiler">
<Writable><Boolean /></Writable>
</Fact>
<Fact path="/formW2s">
<Writable><Collection /></Writable>
</Fact>
<Fact path="/formW2s/*/filer">
<Writable><CollectionItem collection="/filers" /></Writable>
</Fact>
<Fact path="/formW2s/*/wagesTipsOtherComp">
<Writable><Dollar /></Writable>
</Fact>
<Fact path="/primaryFiler">
<Derived>
<Find path="/filers">
<Dependency path="isPrimaryFiler" />
</Find>
</Derived>
</Fact>
<Fact path="/primaryFilerW2s">
<Derived>
<Filter path="/formW2s">
<Dependency path="filer/isPrimaryFiler" />
</Filter>
</Derived>
</Fact>
<Fact path="/primaryFilerWagesTipsOtherComp">
<Derived>
<Sum>
<Dependency path="/primaryFilerW2s/*/wagesTipsOtherComp" />
</Sum>
</Derived>
</Fact>
</Dictionary>
)
val filerId1 = UUID.randomUUID()
val filerId2 = UUID.randomUUID()
val w2Id1 = UUID.randomUUID()
val w2Id2 = UUID.randomUUID()
val w2Id3 = UUID.randomUUID()
val graph = Graph(
dictionary,
InMemoryPersister(
"/filers" -> Collection(Vector(filerId1, filerId2)),
"/formW2s" -> Collection(Vector(w2Id1, w2Id2, w2Id3)),
s"/filers/#$filerId1/firstName" -> "Alice",
s"/filers/#$filerId2/firstName" -> "Bob",
s"/filers/#$filerId1/isPrimaryFiler" -> true,
s"/filers/#$filerId2/isPrimaryFiler" -> false,
s"/formW2s/#$w2Id1/wagesTipsOtherComp" -> Dollar("25000.00"),
s"/formW2s/#$w2Id2/wagesTipsOtherComp" -> Dollar("50000.00"),
s"/formW2s/#$w2Id3/wagesTipsOtherComp" -> Dollar("100000.00")
)
)
// Take a look at the "/formW2s/*/filer" fact. What do you think it does?
<Fact path="/formW2s/*/filer">
<Writable><CollectionItem collection="/filers" /></Writable>
</Fact>
// This CollectionItem is an alias. It allows us to store a reference to an item
// in the filers Collection, in this case, denoting the filer to whom the W2
// belongs. We can follow this alias, and access facts that are children of the
// referenced CollectionItem.
graph.get(s"/formW2s/#$w2Id1/filer")
graph.get(s"/formW2s/#$w2Id1/filer/firstName")
// Because the filer fact is incomplete, we don't know which firstName is being
// referenced, and so firstName is also incomplete, even though all of the
// firstName facts themselves are defined. Let's fix that by attaching each W2
// to a filer.
graph.set(s"/formW2s/#$w2Id1/filer", CollectionItem(filerId2))
graph.set(s"/formW2s/#$w2Id2/filer", CollectionItem(filerId1))
graph.set(s"/formW2s/#$w2Id3/filer", CollectionItem(filerId1))
graph.save()
graph.get(s"/formW2s/#$w2Id1/filer/firstName")
// As you might expect, just as we can have a Writable CollectionItem, we can
// also define a Derived CollectionItem alias. The primaryFiler fact does
// exactly that.
<Fact path="/primaryFiler">
<Derived>
<Find path="/filers">
<Dependency path="isPrimaryFiler" />
</Find>
</Derived>
</Fact>
graph.get("/primaryFiler/firstName")
// Note that the scope of the Dependency is different than we've seen before:
// it's relative to the CollectionItem that we're checking, not to the location
// of the primaryFiler fact.
//
// We can also have Derived Collections.
<Fact path="/primaryFilerW2s">
<Derived>
<Filter path="/formW2s">
<Dependency path="filer/isPrimaryFiler" />
</Filter>
</Derived>
</Fact>
graph.get("/primaryFilerW2s")
// Here we have an aliased Collection, where the predicate is following an
// aliased CollectionItem to determine whether to include the W2. Around and
// around it goes.
//
// Even though they are aliases, we can use these Derived Collections and
// CollectionItems as we would any other.
<Fact path="/primaryFilerWagesTipsOtherComp">
<Derived>
<Sum>
<Dependency path="/primaryFilerW2s/*/wagesTipsOtherComp" />
</Sum>
</Derived>
</Fact>
graph.get("primaryFilerWagesTipsOtherComp")
// In the next chapter, we'll go deeper on navigating the Fact Graph.

View file

@ -0,0 +1,119 @@
package gov.irs.factgraph
// Only children that are at present determing the result of a node are included
// in the explanation. Children are formatted as lists of lists.
//
// [[A, B]] indicates that both A and B will always be required in order to
// calculate a result, while [[A], [B]] indicates that were the result of A or B
// to independently change, we might no longer require the other one.
//
// Here are some examples to illustrate this concept.
//
// An <Any> node only cares about whether it has a true value; order doesn't
// matter. As a result, a complete, true node will provide only a single child
// explanation, indicating the child that made it true. However, if the node is
// false, or incomplete, it will provide a child explanation for each of its
// children using the [[A], [B]] format, as a change in any of them could affect
// the result, and moot the others.
//
// An <All> node, on the other hand, only cares about whether it has a false
// value. As a result, it works exactly the opposite, providing a single
// explanation if it contains a complete, false node, and explaining everything
// using the [[A], [B]] format if not.
//
// A <Switch> statement must check each case sequentially. Imagine a <Switch>
// with three cases WhenA, WhenB, and WhenC each of which has an equivalent
// then statement ThenA, ThenB, and ThenC, respectively. If WhenA and WhenC
// are false, and only WhenB is true, the node will have the following children:
//
// [[WhenA], [WhenB, ThenB]]
//
// Because a change to the values of ThenA, WhenC, and ThenC would not affect
// the result. On the other hand, if WhenA was false, but WhenB was incomplete,
// the children would be:
//
// [[WhenA], [WhenB]]
//
// This expresses that only a change in WhenA or WhenB could affect the result,
// although a change in either of them could make ThenA, ThenB, WhenC, and/or
// ThenC newly relevant.
//
// A <Placeholder> node only explains its source value; we aren't interested
// in explaining default values.
enum Explanation:
case Constant
case Writable(complete: Boolean, path: Path)
case Operation(childList: List[List[Explanation]])
case Dependency(
complete: Boolean,
source: Path,
target: Path,
childList: List[List[Explanation]],
)
case NotAttachedToGraph
def children: List[List[Explanation]] = this match
case Operation(children) => children
case Dependency(_, _, _, children) => children
case _ => List()
def solves: List[List[Path]] = this match
case Writable(false, path) => List(List(path))
case _ =>
// X = [[A, B], [C, D]]
// Y = [[E, F]]
// Z = [[G], [H]]
//
// [[X], [Y]] = [[A, B], [C, D], [E, F]]
// [[X], [Z]] = [[A, B], [C, D], [G], [H]]
// [[X, Y]] = [[A, B, E, F], [C, D, E, F]]
// [[X, Z]] = [[A, B, G], [A, B, H], [C, D, G], [C, D, H]]
// [[X, Y, Z]] = [[A, B, E, F, G], [A, B, E, F, H], [C, D, E, F, G], [C, D, E, F, H]]
// [[X, Y], [Z]] -> [[[[A, B], [C, D]], [[E, F]]], [[[G], [H]]]]
val expandedSets = for {
set <- children
} yield for {
explanation <- set
} yield explanation.solves
expandedSets.flatMap { (setOfSets) => // [[[A, B], [C, D]], [[E, F]]]
setOfSets.reduce { (acc, newSets) =>
// acc: [[A, B], [C, D]]
// newSets: [[E, F]]
for {
set1 <- acc // [A, B]
set2 <- newSets // [E, F]
} yield set1 ++ set2
}
}
def incompleteDependencies: List[(Path, Path)] =
findIncompleteDependencies(List(this), List()).reverse
@annotation.tailrec
private def findIncompleteDependencies(
list: List[Explanation],
acc: List[(Path, Path)],
): List[(Path, Path)] = list match
case explanation :: next =>
val children = explanation.children.flatten
val incompletes = explanation match
case Dependency(false, source, target, _) => (source, target) +: acc
case _ => acc
findIncompleteDependencies(children ++ next, incompletes)
case Nil => acc
object Explanation:
def opWithInclusiveChildren(children: List[Explanation]): Explanation =
Explanation.Operation(List(children))
def opWithInclusiveChildren(children: Explanation*): Explanation =
opWithInclusiveChildren(children.toList)
def opWithExclusiveChildren(children: List[Explanation]): Explanation =
Explanation.Operation(for child <- children yield List(child))
def opWithExclusiveChildren(children: Explanation*): Explanation =
opWithExclusiveChildren(children.toList)

View file

@ -0,0 +1,314 @@
package gov.irs.factgraph
import gov.irs.factgraph.operators.*
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.types.{CollectionItem, WritableType}
import gov.irs.factgraph.compnodes.Placeholder
enum Expression[A]:
case Constant(a: Option[A])
case Writable(klass: Class[A])
case Dependency(path: Path)
case Switch(cases: List[(Expression[Boolean], Expression[A])])
case Extract[A, X](f: (source: Result[X]) => Result[A]) extends Expression[A]
case Unary[A, X](
x: Expression[X],
op: UnaryOperator[A, X],
) extends Expression[A]
case Binary[A, L, R](
lhs: Expression[L],
rhs: Expression[R],
op: BinaryOperator[A, L, R],
) extends Expression[A]
case Arity4[A, W, X, Y, Z](
arg1: Expression[W],
arg2: Expression[X],
arg3: Expression[Y],
arg4: Expression[Z],
op: Arity4Operator[A, W, X, Y, Z],
) extends Expression[A]
case Reduce(
xs: List[Expression[A]],
op: ReduceOperator[A],
)
// Returns the set of strings where the corresponding booleans are true.
// If any string or boolean are incomplete, ConditionalList will return
// Result.Incomplete
case ConditionalList(options: List[(Expression[Boolean], Expression[String])]) extends Expression[List[String]]
case Aggregate[A, X](
x: Expression[X],
op: AggregateOperator[A, X],
) extends Expression[A]
case Collect[A, X](
path: Path,
x: Expression[X],
op: CollectOperator[A, X],
) extends Expression[A]
def isWritable: Boolean = this match
case Writable(_) => true
case _ => Placeholder.isWritablePlaceholder(this)
def get(using Factual): MaybeVector[Result[A]] = this match
case Constant(a) => MaybeVector(Result(a))
case Writable(klass) => MaybeVector(getSavedResult(klass))
case Dependency(path) => dependency(path, dependencies.result)
case Switch(cases) => switch(cases, switches.result)
case Extract(f) => extract(f)
case Unary(x, op) => op(x.get)
case Binary(lhs, rhs, op) => op(lhs.get, rhs.getThunk)
case Arity4(arg1, arg2, arg3, arg4, op) =>
op(arg1.get, arg2.get, arg3.get, arg4.get)
case Reduce(xs, op) => op(xs.head.get, xs.tail.map(_.getThunk))
case ConditionalList(l) => MaybeVector(conditionSet(l))
case Aggregate(x, op) => MaybeVector(op(x.getThunk))
case Collect(path, x, op) => MaybeVector(collect(path, x, op))
def getThunk(using Factual): MaybeVector[Thunk[Result[A]]] = this match
case Constant(a) => MaybeVector(Thunk(() => Result(a)))
case Writable(klass) => MaybeVector(Thunk(() => getSavedResult(klass)))
case Dependency(path) => dependency(path, dependencies.thunk)
case Switch(cases) => switch(cases, switches.thunk)
case Extract(f) => extractThunk(f)
case Unary(x, op) => op.thunk(x.getThunk)
case Binary(lhs, rhs, op) => op.thunk(lhs.getThunk, rhs.getThunk)
case Arity4(arg1, arg2, arg3, arg4, op) =>
op.thunk(arg1.getThunk, arg2.getThunk, arg3.getThunk, arg4.getThunk)
case Reduce(xs, op) => op.thunk(xs.head.getThunk, xs.tail.map(_.getThunk))
case ConditionalList(l) => MaybeVector(Thunk(() => conditionSet(l)))
case Aggregate(x, op) => MaybeVector(op.thunk(x.getThunk))
case Collect(path, x, op) => MaybeVector(Thunk(() => collect(path, x, op)))
def explain(using fact: Factual): MaybeVector[Explanation] = this match
case Constant(_) => MaybeVector(Explanation.Constant)
case Writable(_) =>
for result <- get yield Explanation.Writable(result.complete, fact.path)
case Dependency(path) => dependency(path, dependencies.explain)
case Switch(cases) => switchExplain(cases)
case Extract(f) => fact(PathItem.Parent)(0).get.explain
case Unary(x, op) => op.explain(x)
case Binary(lhs, rhs, op) => op.explain(lhs, rhs)
case Arity4(arg1, arg2, arg3, arg4, op) =>
op.explain(arg1, arg2, arg3, arg4)
case Reduce(xs, op) => op.explain(xs)
case ConditionalList(l) => conditionExplain(l)
case Aggregate(x, op) => op.explain(x)
case Collect(path, x, op) => op.explain(path, x)
def set(value: WritableType)(using fact: Factual): Unit = this match
case Writable(_) =>
fact match
case fact: Fact => fact.graph.persister.setFact(fact, value)
case _ =>
def delete()(using fact: Factual): Unit = this match
case Writable(_) =>
fact match
case fact: Fact => fact.graph.persister.deleteFact(fact)
case _ =>
private def getSavedResult(klass: Class[A])(using fact: Factual): Result[A] =
fact match
case fact: Fact => fact.graph.persister.getSavedResult(fact.path, klass)
case _ => Result.Incomplete
private def dependency[X](
path: Path,
f: (Result[Factual], Path, Path) => MaybeVector[X],
)(using
fact: Factual,
): MaybeVector[X] =
for {
result <- fact(path)
vect <- f(result, fact.path, path)
} yield vect
private object dependencies:
def result(
result: Result[Factual],
_1: Path,
_2: Path,
): MaybeVector[Result[A]] = result match
case Result(fact, complete) =>
val results = fact.get.asInstanceOf[MaybeVector[Result[A]]]
if (!complete) results.map(_.asPlaceholder) else results
case _ => MaybeVector(Result.Incomplete)
def thunk(
result: Result[Factual],
_1: Path,
_2: Path,
): MaybeVector[Thunk[Result[A]]] =
result match
case Result(fact, complete) =>
val thunks = fact.size match
case Factual.Size.Single =>
MaybeVector(Thunk(() => fact.get(0).asInstanceOf[Result[A]]))
case Factual.Size.Multiple =>
fact.getThunk.asInstanceOf[MaybeVector[Thunk[Result[A]]]]
if (!complete)
for {
thunk <- thunks
} yield for {
result <- thunk
} yield result.asPlaceholder
else thunks
case _ => MaybeVector(Thunk(() => Result.Incomplete))
def explain(
result: Result[Factual],
source: Path,
target: Path,
): MaybeVector[Explanation] =
result match
case Result(fact, complete) =>
for {
explanation <- fact.explain
} yield Explanation.Dependency(
complete,
source,
target,
List(List(explanation)),
)
case _ =>
MaybeVector(Explanation.Dependency(false, source, target, List()))
private def switch[X](
cases: List[(Expression[Boolean], Expression[A])],
f: List[(Thunk[Result[Boolean]], Thunk[Result[A]])] => X,
)(using Factual): MaybeVector[X] =
val thunks = cases.map((bool, a) => (bool.getThunk, a.getThunk))
MaybeVector.vectorizeListTuple2(f, thunks)
private def switchExplain(
cases: List[(Expression[Boolean], Expression[A])],
)(using Factual): MaybeVector[Explanation] =
val caseVectors = cases.map((bool, a) =>
(
bool.getThunk,
bool.explain,
a.explain,
), // Potential optimization: thunk the explanations
)
MaybeVector.vectorizeListTuple3(switches.explain, caseVectors)
private object switches:
def result(
cases: List[(Thunk[Result[Boolean]], Thunk[Result[A]])],
): Result[A] =
resultRecurse(cases, true)
@annotation.tailrec
private def resultRecurse(
cases: List[(Thunk[Result[Boolean]], Thunk[Result[A]])],
accComplete: Boolean,
): Result[A] = cases match
case (bool, a) :: next =>
val complete = bool.get.complete && accComplete
bool.get.value match
case Some(true) => if (complete) a.get else a.get.asPlaceholder
case _ => resultRecurse(next, complete)
case Nil => Result.Incomplete
def thunk(
cases: List[(Thunk[Result[Boolean]], Thunk[Result[A]])],
): Thunk[Result[A]] =
Thunk(() => result(cases))
def explain(
cases: List[(Thunk[Result[Boolean]], Explanation, Explanation)],
): Explanation =
explainRecurse(cases, Explanation.Operation(List()))
@annotation.tailrec
def explainRecurse(
cases: List[(Thunk[Result[Boolean]], Explanation, Explanation)],
explanation: Explanation,
): Explanation = cases match
case (bool, boolExp, aExp) :: next =>
bool.get match
case Result.Complete(true) =>
Explanation.Operation(
explanation.children :+ List(boolExp, aExp),
)
case Result.Complete(false) =>
explainRecurse(
next,
Explanation.Operation(explanation.children :+ List(boolExp)),
)
case _ =>
Explanation.Operation(
explanation.children :+ List(boolExp),
)
case Nil => explanation
private def extract[X](
f: (source: Result[X]) => Result[A],
)(using fact: Factual): MaybeVector[Result[A]] =
val parent = fact(PathItem.Parent)(0).get
for {
result <- parent.get
} yield f(result.asInstanceOf[Result[X]])
private def extractThunk[X](
f: (source: Result[X]) => Result[A],
)(using fact: Factual): MaybeVector[Thunk[Result[A]]] =
val parent = fact(PathItem.Parent)(0).get
for {
thunk <- parent.getThunk
} yield for {
result <- thunk
} yield f(result.asInstanceOf[Result[X]])
def collect[X](path: Path, x: Expression[X], op: CollectOperator[A, X])(using
fact: Factual,
): Result[A] =
val vect = for {
item <- fact(path :+ PathItem.Wildcard)
thunk <- x.getThunk(using item.get)
} yield (item.get.get(0).get.asInstanceOf[CollectionItem], thunk)
op(vect)
def conditionSet(list: List[(Expression[Boolean], Expression[String])])(using
fact: Factual,
): Result[List[String]] =
val thunks = list.map(o => (o._1.getThunk, o._2.getThunk))
// If any predicates or options are incomplete, we return Incomplete.
if (thunks.exists((b, s) => b(0).get.complete == false || s(0).get.complete == false)) {
return Result.Incomplete
}
val strings = for {
f <- thunks.filter((b, s) => {
// if a boolean node is false, we exclude its
// value from the returned list.
b(0).get.complete && b(0).get.get == true &&
// Similarly, if the value we're returning is incomplete, we exclude it.
s(0).get.complete
})
} yield {
f._2(0).get.get
}
Result.Complete(strings.toList)
def conditionExplain(list: List[(Expression[Boolean], Expression[String])])(using
fact: Factual,
): MaybeVector[Explanation] =
// First we call explain on each bool and string
val explanations = for (bool, str) <- list yield (bool.explain, str.explain)
// The result is a list of tuples of MaybeVectors, so we need to vectorize
// this operation, which flattens the lists of tuples and returns a
// MaybeVector of an Explanation, with the flattened lists as its children
MaybeVector.vectorizeListTuple2(
(expList: List[(Explanation, Explanation)]) =>
Explanation.opWithInclusiveChildren(
expList.flatten { case (a, b) => List(a, b) },
),
explanations,
)

View file

@ -0,0 +1,275 @@
package gov.irs.factgraph
import gov.irs.factgraph.compnodes.{CollectionItemNode, CollectionNode, CompNode, RootNode}
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.types.{Collection, CollectionItem, WritableType}
import gov.irs.factgraph.limits.*
import java.util.UUID
import scala.annotation.tailrec
import scala.scalajs.js.annotation.JSExport
import scala.scalajs.js.annotation.JSExportAll
final class Fact(
@JSExport val value: CompNode,
@JSExport val path: Path,
@JSExport val limits: Seq[Limit],
@JSExport val graph: Graph,
@JSExport val parent: Option[Fact],
@JSExport val meta: Factual.Meta,
) extends Factual:
given Factual = this
export meta.*
@tailrec
def root: Fact = parent match
case Some(parent) => parent.root
case None => this
override def get: MaybeVector[Result[value.Value]] =
graph.resultCache
.getOrElseUpdate(path, { value.get })
.asInstanceOf[MaybeVector[Result[value.Value]]]
override def getThunk: MaybeVector[Thunk[Result[value.Value]]] =
value.getThunk
override def explain: MaybeVector[Explanation] = value.explain
/** Set the value of a writable fact.
*
* @param a
* The new value.
* @param allowCollectionItemDelete
* Whether to allow setting a Collection in a way that removes items.
*/
def set(a: WritableType, allowCollectionItemDelete: Boolean = false): Unit =
if (!value.expr.isWritable)
throw new Exception(s"${path} is not writable")
if (!allowCollectionItemDelete)
a match
case Collection(newValues) =>
graph.getVect(path) match
case MaybeVector.Single(Result(Collection(oldValues), _)) =>
if (oldValues.exists(oldValue => !newValues.contains(oldValue)))
throw new Exception(
s"Cannot use set() to remove items(s) from collection ${path}",
)
case _ =>
case _ =>
if (!value.ValueClass.isAssignableFrom(a.getClass()))
throw new Exception(
s"${path} is expecting '${value.ValueClass}', received '${a.getClass()}' instead",
)
graph.persister.setFact(this, a)
def validate(): Seq[LimitViolation] =
limits.map(x => x.run()).filter(x => x.isDefined).map(x => x.get)
def delete(): Unit =
val collectionPath = parent.get.path
graph.getVect(collectionPath) match
case MaybeVector.Single(Result(Collection(collection), _)) =>
val deleted = path.getMemberId.get
graph.persister.deleteFact(this, true)
graph(collectionPath)(0).get
.set(Collection(collection.filter((uuid) => uuid != deleted)), true)
case _ if value.expr.isWritable =>
graph.persister.deleteFact(this, false)
case _ =>
override def apply(path: Path): MaybeVector[Result[Fact]] =
this(path, true)
override def apply(key: PathItem): MaybeVector[Result[Fact]] =
this(List(key), true)
private def apply(
path: Path,
accComplete: Boolean,
): MaybeVector[Result[Fact]] =
if (path.absolute) root(path.items, accComplete)
else this(path.items, accComplete)
private def apply(
pathItems: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] = pathItems match
case PathItem.Parent :: next => applyNext(parent, next, accComplete)
case PathItem.Child(_) :: _ => applyChild(pathItems, accComplete)
case PathItem.Wildcard :: _ => applyWildcard(pathItems, accComplete)
case PathItem.Member(id) :: _ => applyMember(id, pathItems, accComplete)
case PathItem.Unknown :: _ => applyUnknown(pathItems)
case Nil => MaybeVector(Result(this, accComplete))
private def applyChild(
pathItems: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] = value match
case CollectionItemNode(item, Some(alias)) =>
applyNextFollowingAlias(item, alias, pathItems, accComplete)
case _ => applyNext(getChild(pathItems.head), pathItems.tail, accComplete)
private def applyWildcard(
pathItems: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] = value match
case CollectionNode(collection, Some(alias)) =>
mapCollectionItems(collection, accComplete) { id =>
followAlias(alias, PathItem.Member(id) +: pathItems.tail, true)
}
case CollectionNode(collection, None) =>
mapCollectionItems(collection, accComplete) { id =>
applyNext(getMember(PathItem.Member(id)), pathItems.tail, true)
}
case _ => MaybeVector(Result.Incomplete)
private def applyMember(
id: UUID,
pathItems: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] = value match
case CollectionNode(collection, Some(alias)) =>
mapCollectionItemIfInCollection(collection, id) {
followAlias(alias, pathItems, accComplete)
}
case CollectionNode(collection, None) =>
mapCollectionItemIfInCollection(collection, id) {
applyNext(getMember(pathItems.head), pathItems.tail, accComplete)
}
case _ => MaybeVector(Result.Incomplete)
private def applyUnknown(
pathItems: List[PathItem],
): MaybeVector[Result[Fact]] = value match
case CollectionNode(collection, Some(alias)) =>
followAlias(alias, pathItems, false)
case CollectionNode(collection, None) =>
applyNext(getMember(pathItems.head), pathItems.tail, false)
case _ => MaybeVector(Result.Incomplete)
private def applyNext(
optFact: Option[Fact],
next: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] =
for {
result <- MaybeVector(Result(optFact))
vect <- result match
case Result(fact, complete) => fact(next, complete && accComplete)
case _ => MaybeVector(Result.Incomplete)
} yield vect
private def applyNextFollowingAlias(
collectionItemExpr: Expression[CollectionItem],
alias: Path,
next: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] =
for {
result <- collectionItemExpr.get
vect <- result match
case Result(item, complete) =>
followAlias(
alias,
PathItem.Member(item.id) +: next,
complete && accComplete,
)
case _ => followAlias(alias, PathItem.Unknown +: next, false)
} yield vect
private def followAlias(
alias: Path,
next: List[PathItem],
accComplete: Boolean,
): MaybeVector[Result[Fact]] =
this(alias ++ next, accComplete)
private def getChild(key: PathItem): Option[Fact] =
val childPath = path :+ key
graph.factCache.getOrElseUpdate(childPath, { makeFact(key) })
private def getMember(key: PathItem): Option[Fact] =
val memberPath = path :+ key
graph.factCache.getOrElseUpdate(memberPath, { makeExtract(key) })
private def makeFact(key: PathItem): Option[Fact] =
makeExtract(key).orElse({
graph
.dictionary(meta.abstractPath :+ key)
.map(_.attachToGraph(this, key))
})
private def makeExtract(key: PathItem): Option[Fact] =
value
.extract(key)
.map(node =>
val extractMeta = Factual.Meta(
size,
meta.abstractPath :+ key.asAbstract,
)
Fact(node, this, limits, key, extractMeta),
)
private def mapCollectionItems(
collectionExpr: Expression[Collection],
accComplete: Boolean,
): (f: UUID => MaybeVector[Result[Fact]]) => MaybeVector[Result[Fact]] =
f =>
for {
collection <- collectionExpr.get
vect <- collection match
case Result(collection, complete) =>
for {
id <- MaybeVector(collection.items, complete && accComplete)
result <- f(id)
} yield result
case _ => MaybeVector(Nil, false)
} yield vect
private def mapCollectionItemIfInCollection(
collectionExpr: Expression[Collection],
id: UUID,
): (=> MaybeVector[Result[Fact]]) => MaybeVector[Result[Fact]] =
x =>
for {
collection <- collectionExpr.get
result <- collection match
case Result(collection, _) if collection.items.contains(id) => x
case _ => MaybeVector(Result.Incomplete)
} yield result
object Fact:
private val RootNode = new RootNode()
private val RootMeta = Factual.Meta(
Factual.Size.Single,
Path.Root,
)
def apply(graph: Graph): Fact = new Fact(
RootNode,
Path.Root,
Seq.empty,
graph,
None,
RootMeta,
)
def apply(
value: CompNode,
parent: Fact,
limits: Seq[Limit],
pathItem: PathItem,
meta: Factual.Meta,
): Fact =
new Fact(
value,
parent.path :+ pathItem,
limits,
parent.graph,
Some(parent),
meta,
)

View file

@ -0,0 +1,183 @@
package gov.irs.factgraph
import gov.irs.factgraph
import gov.irs.factgraph.compnodes.{CollectionItemNode, CollectionNode, CompNode, WritableNode}
import gov.irs.factgraph.definitions.fact.{FactConfigTrait, LimitConfigTrait}
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.limits.*
import gov.irs.factgraph.compnodes.Placeholder
import scala.scalajs.js.annotation.JSExport
import scala.scalajs.js.annotation.JSExportAll
final class FactDefinition(
private val cnBuilder: Factual ?=> CompNode,
val path: Path,
private val limitsBuilder: Factual ?=> Seq[Limit],
val dictionary: FactDictionary,
) extends Factual:
given Factual = this
def attachToGraph(parent: Fact, key: PathItem): Fact =
Fact(value, parent, limits, key, meta)
def asTuple: (Path, FactDefinition) = (path, this)
@JSExport
lazy val value: CompNode = cnBuilder
@JSExport
lazy val limits: Seq[Limit] = limitsBuilder.map(x => x)
@JSExport
lazy val meta: Factual.Meta = Factual.Meta(
size,
abstractPath,
)
lazy val size: Factual.Size = value.getThunk match
case MaybeVector.Single(_) => Factual.Size.Single
case MaybeVector.Multiple(_, _) => Factual.Size.Multiple
@JSExport
def abstractPath: Path = path
override def get: MaybeVector[Result[value.Value]] = size match
case Factual.Size.Single => MaybeVector(Result.Incomplete)
case Factual.Size.Multiple => MaybeVector(Nil, true)
override def getThunk: MaybeVector[Thunk[Result[value.Value]]] = size match
case Factual.Size.Single => MaybeVector(Thunk(() => Result.Incomplete))
case Factual.Size.Multiple => MaybeVector(Nil, true)
override def explain: MaybeVector[Explanation] = size match
case Factual.Size.Single => MaybeVector(Explanation.NotAttachedToGraph)
case Factual.Size.Multiple => MaybeVector(Nil, true)
private def root: FactDefinition =
dictionary(Path.Root).get
private def parent: Option[FactDefinition] =
path.parent.flatMap(_.head) match
case Some(PathItem.Wildcard) =>
for {
parentPath <- path.parent
grandparentPath <- parentPath.parent
collection <- dictionary(grandparentPath)
collectionItem <- collection(PathItem.Unknown)(0).value
} yield collectionItem
case _ =>
for {
parentPath <- path.parent
fact <- dictionary(parentPath)
} yield fact
override def apply(path: Path): MaybeVector[Result[FactDefinition]] =
if (path.absolute) root(path.items) else this(path.items)
override def apply(key: PathItem): MaybeVector[Result[FactDefinition]] =
this(List(key))
private def apply(
pathItems: List[PathItem],
): MaybeVector[Result[FactDefinition]] = pathItems match
case PathItem.Parent :: next => getNext(parent, next)
case PathItem.Child(_) :: _ => applyChild(pathItems)
case PathItem.Wildcard :: _ => applyWildcard(pathItems)
case PathItem.Member(_) :: _ => MaybeVector(Result.Incomplete)
case PathItem.Unknown :: _ => applyUnknown(pathItems)
case Nil => MaybeVector(Result.Complete(this))
private def applyChild(
pathItems: List[PathItem],
): MaybeVector[Result[FactDefinition]] = value match
case CollectionItemNode(_, Some(alias)) =>
this((alias :+ PathItem.Unknown) ++ pathItems)
case _ => getNext(getChild(pathItems.head), pathItems.tail)
private def applyWildcard(
pathItems: List[PathItem],
): MaybeVector[Result[FactDefinition]] = value match
case CollectionNode(_, Some(alias)) =>
this(alias ++ pathItems)
case CollectionNode(_, None) =>
getNext(getExtract(PathItem.Unknown), pathItems.tail).toMultiple
case _ => MaybeVector(Result.Incomplete)
private def applyUnknown(
pathItems: List[PathItem],
): MaybeVector[Result[FactDefinition]] = value match
case CollectionNode(_, Some(alias)) =>
this(alias ++ pathItems)
case CollectionNode(_, None) =>
getNext(getExtract(pathItems.head), pathItems.tail)
case _ => MaybeVector(Result.Incomplete)
private def getNext(
optFact: Option[FactDefinition],
next: List[PathItem],
): MaybeVector[Result[FactDefinition]] =
for {
result <- MaybeVector(Result(optFact))
vect <- result.value match
case Some(fact) => fact(next)
case None => MaybeVector(Result.Incomplete)
} yield vect
private def getChild(key: PathItem): Option[FactDefinition] =
getExtract(key).orElse(dictionary(path :+ key))
private def getExtract(key: PathItem): Option[FactDefinition] =
value
.extract(key)
.map(node =>
new FactDefinition(
Factual ?=> node,
path :+ key.asAbstract,
Seq.empty,
dictionary,
),
)
object FactDefinition:
def apply(
cnBuilder: Factual ?=> CompNode,
path: Path,
limits: Factual ?=> Seq[Limit],
dictionary: FactDictionary,
): FactDefinition =
require(path.isAbstract)
val definition = new FactDefinition(cnBuilder, path, limits, dictionary)
dictionary.addDefinition(definition)
definition
def fromConfig(e: FactConfigTrait)(using FactDictionary): FactDefinition =
// if neither of them or both of them
if (e.writable.isEmpty && e.derived.isEmpty || (e.writable.isDefined && e.derived.isDefined))
throw new IllegalArgumentException(
"Fact must have exactly one Writable or Derived",
)
val isWritable = e.writable.isDefined
val cnBuilder: Factual ?=> CompNode =
val node =
if (isWritable) WritableNode.fromConfig(e.writable.get)
else CompNode.fromDerivedConfig(e.derived.get)
e.placeholder match
case Some(default) =>
Placeholder(node, CompNode.fromDerivedConfig(default))
case None => node
val limits: Factual ?=> Seq[Limit] =
if (isWritable)
e.writable.get.limits
.map(x => Limit.fromConfig(x))
.toSeq
.concat(WritableNode.fromConfig(e.writable.get).getIntrinsicLimits())
else
Seq.empty
val dictionary = summon[FactDictionary]
this(cnBuilder, Path(e.path), limits, dictionary)

View file

@ -0,0 +1,77 @@
package gov.irs.factgraph
import gov.irs.factgraph.compnodes.RootNode
import gov.irs.factgraph.definitions.FactDictionaryConfigTrait
import gov.irs.factgraph.definitions.meta.{EnumDeclarationTrait, MetaConfigTrait}
import gov.irs.factgraph.Meta
import scala.collection.mutable
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import gov.irs.factgraph.compnodes.MultiEnumNode
import gov.irs.factgraph.compnodes.EnumNode
class FactDictionary:
private val definitions: mutable.Map[Path, FactDefinition] = mutable.Map()
private var frozen: Boolean = false
private var meta: MetaConfigTrait = Meta.empty()
def getPaths(): Iterable[Path] =
definitions.keys
def freeze(): Unit =
for {
(_, definition) <- definitions
} definition.meta
if (meta == Meta.empty())
throw new UnsupportedOperationException(
"Must provide meta information to FactDictionary",
)
frozen = true
def apply(path: Path): Option[FactDefinition] = definitions.get(path)
@JSExport("getDefinition")
def apply(path: String): FactDefinition | Null =
definitions.get(Path(path)) match
case Some(value) => value
case _ => null
@JSExport
def getMeta(): MetaConfigTrait = meta
@JSExport("getOptionsPathForEnum")
def getOptionsPathForEnum(enumPath: String): Option[String] =
val factDef = this(enumPath)
factDef.value match
case value: EnumNode => Some(value.enumOptionsPath.toString)
case value: MultiEnumNode => Some(value.enumOptionsPath.toString)
case _ => None
protected[factgraph] def addDefinition(definition: FactDefinition): Unit =
if (frozen)
throw new UnsupportedOperationException(
"cannot add definitions to a frozen FactDictionary",
)
definitions.addOne(definition.asTuple)
protected[factgraph] def addMeta(metaConfigTrait: MetaConfigTrait): Unit =
if (frozen)
throw new UnsupportedOperationException(
"Meta configuration must be added before freezing the dictionary",
)
meta = metaConfigTrait
object FactDictionary:
def apply(): FactDictionary =
val dictionary = new FactDictionary()
FactDefinition(RootNode(), Path.Root, Seq.empty, dictionary)
dictionary
@JSExport
def fromConfig(e: FactDictionaryConfigTrait): FactDictionary =
val dictionary = this()
Meta.fromConfig(e.meta, dictionary)
e.facts.map(FactDefinition.fromConfig(_)(using dictionary))
dictionary.freeze()
dictionary

View file

@ -0,0 +1,32 @@
package gov.irs.factgraph
import gov.irs.factgraph.compnodes.CompNode
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.limits.*
trait Factual:
def value: CompNode
def path: Path
def meta: Factual.Meta
def size: Factual.Size
def abstractPath: Path
def limits: Seq[Limit]
def get: MaybeVector[Result[?]]
def getThunk: MaybeVector[Thunk[Result[?]]]
def explain: MaybeVector[Explanation]
def isWritable: Boolean = value.isWritable
def apply(path: Path): MaybeVector[Result[Factual]]
def apply(key: PathItem): MaybeVector[Result[Factual]]
object Factual:
enum Size:
case Single
case Multiple
final class Meta(
val size: Size,
val abstractPath: Path,
)

View file

@ -0,0 +1,120 @@
package gov.irs.factgraph
import gov.irs.factgraph.limits.LimitViolation
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.persisters.*
import gov.irs.factgraph.types.{Collection, CollectionItem, WritableType, Enum}
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.collection.mutable
import scala.scalajs.js.annotation.JSExportAll
class Graph(val dictionary: FactDictionary, val persister: Persister):
val root: Fact = Fact(this)
private[factgraph] val factCache = mutable.HashMap[Path, Option[Fact]]()
private[factgraph] val resultCache =
mutable.HashMap[Path, MaybeVector[Result[Any]]]()
export root.apply
@JSExport("get")
def get(path: String): Result[Any] = get(Path(path))
@JSExport("getWithPath")
def get(path: Path): Result[Any] = getVect(path) match
case MaybeVector.Single(result) => result
case MaybeVector.Multiple(_, _) =>
throw new UnsupportedOperationException(
s"must use getVect to access '$path'",
)
@JSExport("getVect")
def getVect(path: String): MaybeVector[Result[Any]] = getVect(Path(path))
@JSExport("getVectWithPath")
def getVect(path: Path): MaybeVector[Result[Any]] =
for {
fact <- this(path)
values <- fact match
case Result(fact, complete) =>
if (complete) fact.get
else fact.get.map(_.asPlaceholder)
case _ =>
throw new UnsupportedOperationException(
s"path '$path' was not found",
)
} yield values
@JSExport("explain")
def explain(path: String): Explanation = explain(Path(path))
@JSExport("explainWithPath")
def explain(path: Path): Explanation =
val explanations = for {
fact <- this(path)
explanation <- fact match
// Note that we are discarding the completeness of the path's
// resolution; we are providing an explanation of a *fact's* result,
// not why a particular *path* returns a potentially incomplete result.
case Result(fact, _) =>
fact.explain
case _ =>
throw new UnsupportedOperationException(
s"path '$path' was not found",
)
} yield explanation
explanations match
case MaybeVector.Single(explanation) => explanation
case MaybeVector.Multiple(_, _) =>
throw new UnsupportedOperationException(
s"path '$path' resolves to a vector",
)
@JSExport("set")
def set(path: String, value: WritableType): Unit = set(Path(path), value)
@JSExport("setWithPath")
def set(path: Path, value: WritableType): Unit =
for {
result <- this(path)
fact <- result
} fact.set(value)
@JSExport("delete")
def delete(path: String): Unit = delete(Path(path))
@JSExport("deleteWithPath")
def delete(path: Path): Unit =
for {
result <- this(path)
fact <- result
} fact.delete()
def checkPersister(): Seq[PersisterSyncIssue] =
persister.syncWithDictionary(this)
def save(): (Boolean, Seq[LimitViolation]) =
factCache.clear()
resultCache.clear()
val out = persister.save()
// Don't cache invalid results
if !out._1 then resultCache.clear()
out
@JSExport("getDictionary")
def getDictionary() = this.dictionary
def getCollectionPaths(collectionPath: String): Seq[String] =
val paths = for
pathPerhapsWithWildcards <- Path(collectionPath).populateWildcards(this)
pathWithoutWildcards <- pathPerhapsWithWildcards.populateWildcards(this)
yield pathWithoutWildcards.toString
paths.toSeq
object Graph:
def apply(dictionary: FactDictionary): Graph =
this(dictionary, InMemoryPersister())
def apply(dictionary: FactDictionary, persister: Persister): Graph =
new Graph(dictionary, persister)

View file

@ -0,0 +1,14 @@
package gov.irs.factgraph
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.definitions.meta.{EnumDeclarationTrait, MetaConfigTrait}
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import gov.irs.factgraph.definitions.meta.EnumDeclarationOptionsTrait
case class Meta(val version: String) extends MetaConfigTrait:
def getVersion() = version
object Meta:
def empty(): Meta = new Meta("Invalid")
def fromConfig(e: MetaConfigTrait, factDictionary: FactDictionary): Unit =
factDictionary.addMeta(new Meta(e.version))

View file

@ -0,0 +1,44 @@
package gov.irs.factgraph
import gov.irs.factgraph.persisters.TypeContainer
import gov.irs.factgraph.types.WritableType
import ujson.Value
import upickle.default.{read, write}
// When we make changes to the Fact Graph or Fact Dictionary, those changes not only get deployed to
// our backend servers, they get deployed to the users' browsers as well.
//
// In order to safely make changes to the Fact Graph, we can define programmatic changes that modify
// existing Fact Graphs. That way in-progress Fact Graphs (which live in the user's browser) can be
// modified to fit the new requirements, before we attempt to load them in.
object Migrations {
val MigrationsFieldName = "/meta/migrationsApplied";
// For each new migration, make a function for it, then add it to this list
// Migrations numbers should increase monotonically, i.e. m0_, m1_, m2_, ...
// It's very important that these migrations stay in order, so don't re-order the list.
// The leading number (i.e. m1) is a way of explicitly denoting that order, but it's the List
// order itself that matters for ensuring consistency.
private val AllMigrations = List(
m1_BlankMigration,
m2_DeleteInvalidAddresses,
)
val TotalMigrations: Int = AllMigrations.length
def run(data: Map[String, Value], numMigrations: Int): Map[Path, WritableType] =
AllMigrations
.drop(numMigrations) // get the missing migrations
.foldLeft(data)((data, migration) => migration(data)) // apply each of them
.map((k, v) => (Path(k), read[TypeContainer](v).item)) // convert the result to Map[Path, WritableType]
// Blank migration to test the mechanism
private def m1_BlankMigration(data: Map[String, Value]): Map[String, Value] =
data
// Remove addresses that don't match MeF validation
private def m2_DeleteInvalidAddresses(data: Map[String, Value]): Map[String, Value] =
data.filterNot((_, value) =>
value("$type").value == "gov.irs.factgraph.persisters.AddressWrapper" &&
!value("item")("streetAddress").str.matches("[A-Za-z0-9]( ?[A-Za-z0-9\\-/])*"),
)
}

View file

@ -0,0 +1,119 @@
package gov.irs.factgraph
import gov.irs.factgraph.monads.{MaybeVector, Result}
import gov.irs.factgraph.types.CollectionItem
import java.util.UUID
final case class Path(private val _items: List[PathItem], absolute: Boolean):
def ++(rhs: Path): Path =
if (rhs.absolute) rhs
else Path(rhs._items ++ _items, absolute)
def ++(rhs: Seq[PathItem]): Path =
val items = rhs.foldLeft(_items)((items, item) => item +: items)
Path(items, absolute)
def :+(item: PathItem): Path = Path(item +: _items, absolute)
def items: List[PathItem] = _items.reverse
def head: Option[PathItem] = _items match
case head :: _ => Some(head)
case Nil => None
def parent: Option[Path] = _items match
case head :: next => Some(Path(next, absolute))
case Nil => None
def isAbstract: Boolean = absolute && _items.forall(_.isAbstract)
def isKnown: Boolean = absolute && _items.forall(_.isKnown)
def isWildcard: Boolean = absolute && _items.exists(_.isWildcard)
def isCollectionMember: Boolean =
absolute && _items.exists(_.isCollectionMember)
def isCollectionItem: Boolean = absolute && _items(0).isCollectionMember
def getMemberId: Option[UUID] =
val itemOpt = _items.find((item) => item.isCollectionMember)
if itemOpt.isEmpty then None
else
itemOpt.get match
case PathItem.Member(uuid) => Some(uuid)
case _ => None
def asAbstract: Path =
items.foldLeft(items.head match
case PathItem.Parent => Path("")
case _ => Path("/"),
)((pathSoFar: Path, nextPathItem: PathItem) =>
pathSoFar :+ (
nextPathItem match
case PathItem.Member(_) => PathItem(PathItem.WildcardKey)
case _ => nextPathItem
),
)
def populateWildcards(
graph: Graph,
pathsWithoutWildcards: Seq[Path] = Nil,
): Seq[Path] =
isWildcard match
case false => this :: pathsWithoutWildcards.toList
case true =>
this
.populateFirstWildcard(graph)
.flatMap((pathPerhapsWithWildcards: Path) =>
pathPerhapsWithWildcards
.populateWildcards(graph, pathsWithoutWildcards),
)
private def populateFirstWildcard(graph: Graph): Seq[Path] =
val pathItemsBeforeFirstWildcard = _items.reverse.takeWhile(!_.isWildcard)
val pathItemsAfterFirstWildcard =
_items.reverse.dropWhile(!_.isWildcard).dropWhile(_.isWildcard)
val pathBeforeWildcard =
pathItemsBeforeFirstWildcard.foldLeft(Path(Path.Delimiter)) { (pathSoFar, pathItem) =>
pathSoFar :+ pathItem
}
try
val vector =
graph.getVect(pathBeforeWildcard :+ PathItem(PathItem.WildcardKey))
vector.match
case MaybeVector.Multiple[Result[CollectionItem]](multiple, complete) if complete =>
for result <- multiple
yield (pathBeforeWildcard :+ PathItem(
s"${PathItem.MemberPrefix}${result.value.get.id}",
)) ++ pathItemsAfterFirstWildcard
case _ => Nil // collection is empty
catch case e: UnsupportedOperationException => Nil
override def toString: String =
val prefix = if (absolute) Path.Delimiter else ""
val path = items.map(_.toString()).mkString(Path.Delimiter)
s"$prefix$path"
object Path:
val Delimiter = "/"
val Root: Path = new Path(Nil, true)
val Relative: Path = new Path(Nil, false)
def apply(str: String): Path = str match
case Delimiter => Root
case "" => Relative
case _ =>
val item_strs = str.split(Delimiter, 0)
val absolute = item_strs(0).isEmpty
val items = item_strs
.drop(if (absolute) 1 else 0)
.map(PathItem(_))
.reverse
.toList
Path(items, absolute)

View file

@ -0,0 +1,54 @@
package gov.irs.factgraph
import upickle.default.ReadWriter
import java.util.UUID
enum PathItem derives ReadWriter:
case Child(key: Symbol)
case Parent
// Collections
case Member(id: UUID)
case Wildcard
case Unknown
def isKnown: Boolean = this match
case Child(_) | Member(_) => true
case _ => false
def isAbstract: Boolean = this match
case Child(_) | Wildcard => true
case _ => false
def isWildcard: Boolean = this match
case Wildcard => true
case _ => false
def isCollectionMember: Boolean = this match
case Member(_) => true
case _ => false
def asAbstract: PathItem = this match
case Member(_) | Unknown => Wildcard
case _ => this
override def toString: String = this match
case Wildcard => PathItem.WildcardKey
case Unknown => PathItem.UnknownKey
case Parent => PathItem.ParentKey
case Member(id) => s"${PathItem.MemberPrefix}${id}"
case Child(key) => key.name
object PathItem:
val WildcardKey = "*"
private val UnknownKey = "?"
private val ParentKey = ".."
val MemberPrefix = '#'
def apply(str: String): PathItem = str match
case WildcardKey => PathItem.Wildcard
case UnknownKey => PathItem.Unknown
case ParentKey => PathItem.Parent
case _ if str.charAt(0) == MemberPrefix =>
PathItem.Member(UUID.fromString(str.substring(1)))
case _ => PathItem.Child(Symbol(str))

View file

@ -0,0 +1,3 @@
package gov.irs.factgraph
case class PersisterSyncIssue(path: String, message: String)

View file

@ -0,0 +1,169 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.{*, given}
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.{BinaryOperator, ReduceOperator}
import gov.irs.factgraph.util.Seq.itemsHaveSameRuntimeClass
import scala.annotation.unused
// Sum is used across multiple child nodes. If you're trying to add
// a maybe vector from a collection, use the `Sum` node.
object Add extends CompNodeFactory:
override val Key: String = "Add"
def apply(nodes: Seq[CompNode]): CompNode =
if (itemsHaveSameRuntimeClass(nodes))
reduceAdd(nodes)
else
nodes.reduceLeft(binaryAdd)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val addends = CompNode.getConfigChildNodes(e)
this(addends)
private def reduceAdd(nodes: Seq[CompNode]): CompNode =
nodes.head match
case node: IntNode =>
IntNode(
Expression.Reduce(
nodes.asInstanceOf[List[IntNode]].map(_.expr),
summon[AddReduceOperator[Int]],
),
)
case node: DollarNode =>
DollarNode(
Expression.Reduce(
nodes.asInstanceOf[List[DollarNode]].map(_.expr),
summon[AddReduceOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Reduce(
nodes.asInstanceOf[List[RationalNode]].map(_.expr),
summon[AddReduceOperator[Rational]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot add a ${nodes.head.getClass.getName}",
)
private def binaryAdd(lhs: CompNode, rhs: CompNode): CompNode =
(lhs, rhs).match
case (lhs: IntNode, rhs: IntNode) =>
IntNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Int, Int, Int]],
),
)
case (lhs: DollarNode, rhs: DollarNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Dollar, Dollar, Dollar]],
),
)
case (lhs: RationalNode, rhs: RationalNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Rational, Rational, Rational]],
),
)
case (lhs: IntNode, rhs: DollarNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Dollar, Int, Dollar]],
),
)
case (lhs: DollarNode, rhs: IntNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Dollar, Dollar, Int]],
),
)
case (lhs: IntNode, rhs: RationalNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Rational, Int, Rational]],
),
)
case (lhs: RationalNode, rhs: IntNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Rational, Rational, Int]],
),
)
case (lhs: RationalNode, rhs: DollarNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Dollar, Rational, Dollar]],
),
)
case (lhs: DollarNode, rhs: RationalNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[AddBinaryOperator[Dollar, Dollar, Rational]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot add a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
)
private final class AddReduceOperator[A: Numeric] extends ReduceOperator[A]:
override protected def reduce(x: A, y: A): A = Numeric[A].plus(x, y)
@unused
private object AddReduceOperator:
implicit val intOperator: AddReduceOperator[Int] = AddReduceOperator[Int]
implicit val dollarOperator: AddReduceOperator[Dollar] =
AddReduceOperator[Dollar]
implicit val rationalOperator: AddReduceOperator[Rational] =
AddReduceOperator[Rational]
// implicit def numericOperator[A: Numeric]: AddReduceOperator[A] =
// AddReduceOperator[A]
private trait AddBinaryOperator[A, L, R] extends BinaryOperator[A, L, R]
@unused
private object AddBinaryOperator:
implicit val intIntOperator: AddBinaryOperator[Int, Int, Int] =
(lhs: Int, rhs: Int) => lhs + rhs
implicit val dollarDollarOperator: AddBinaryOperator[Dollar, Dollar, Dollar] =
(lhs: Dollar, rhs: Dollar) => lhs + rhs
implicit val rationalRationalOperator: AddBinaryOperator[Rational, Rational, Rational] =
(lhs: Rational, rhs: Rational) => lhs + rhs
implicit val dollarIntOperator: AddBinaryOperator[Dollar, Dollar, Int] =
(lhs: Dollar, rhs: Int) => Numeric[Dollar].plus(lhs, rhs)
implicit val intDollarOperator: AddBinaryOperator[Dollar, Int, Dollar] =
(lhs: Int, rhs: Dollar) => Numeric[Dollar].plus(lhs, rhs)
implicit val rationalIntOperator: AddBinaryOperator[Rational, Rational, Int] =
(lhs: Rational, rhs: Int) => Numeric[Rational].plus(lhs, rhs)
implicit val intRationalOperator: AddBinaryOperator[Rational, Int, Rational] =
(lhs: Int, rhs: Rational) => Numeric[Rational].plus(lhs, rhs)
implicit val dollarRationalOperator: AddBinaryOperator[Dollar, Dollar, Rational] =
(lhs: Dollar, rhs: Rational) => Numeric[Dollar].plus(lhs, rhs)
implicit val rationalDollarOperator: AddBinaryOperator[Dollar, Rational, Dollar] =
(lhs: Rational, rhs: Dollar) => Numeric[Dollar].plus(lhs, rhs)

View file

@ -0,0 +1,80 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.monads.{MaybeVector, Result}
import gov.irs.factgraph.types.Address
final case class AddressNode(expr: Expression[Address]) extends CompNode:
type Value = Address
override def ValueClass = classOf[Address]
override private[compnodes] def fromExpression(
expr: Expression[Address],
): CompNode =
AddressNode(expr)
override def extract(key: PathItem): Option[CompNode] =
key match
case PathItem.Child(Symbol("streetAddress")) =>
Some(
StringNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.streetAddress)),
),
)
case PathItem.Child(Symbol("city")) =>
Some(
StringNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.city)),
),
)
case PathItem.Child(Symbol("postalCode")) =>
Some(
StringNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.postalCode)),
),
)
case PathItem.Child(Symbol("stateOrProvence")) =>
Some(
StringNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.stateOrProvence)),
),
)
case PathItem.Child(Symbol("streetAddressLine2")) =>
Some(
StringNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.streetAddressLine2)),
),
)
case PathItem.Child(Symbol("country")) =>
Some(
StringNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.country)),
),
)
case PathItem.Child(Symbol("foreignAddress")) =>
Some(
BooleanNode(
Expression.Extract((x: Result[Address]) => x.map(y => y.foreignAddress())),
),
)
case _ => None
object AddressNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "Address"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new AddressNode(
Expression.Writable(classOf[Address]),
)
def apply(value: Address): AddressNode = new AddressNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(Address(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,80 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.{Explanation, Expression, FactDictionary, Factual}
import gov.irs.factgraph.operators.ReduceOperator
import gov.irs.factgraph.monads.{MaybeVector, Result, Thunk}
object All extends CompNodeFactory:
override val Key: String = "All"
private val operator = AllOperator()
def apply(nodes: Seq[BooleanNode]): BooleanNode =
BooleanNode(Expression.Reduce(nodes.map(_.expr).toList, operator))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val conditions = CompNode.getConfigChildNodes(e)
if (conditions.forall(_.isInstanceOf[BooleanNode]))
this(conditions.asInstanceOf[Seq[BooleanNode]])
else
throw new UnsupportedOperationException(
"all children of <All> must be BooleanNodes",
)
private final class AllOperator extends ReduceOperator[Boolean]:
// $COVERAGE-OFF$
override protected def reduce(x: Boolean, y: Boolean): Boolean = ???
// $COVERAGE-ON$
override def apply(
head: Result[Boolean],
tail: List[Thunk[Result[Boolean]]],
): Result[Boolean] = head match
case Result.Complete(false) => Result.Complete(false)
case _ => accumulator(tail, head)
@scala.annotation.tailrec
private def accumulator(
thunks: List[Thunk[Result[Boolean]]],
a: Result[Boolean],
): Result[Boolean] = thunks match
case thunk :: thunks =>
thunk.get match
case Result.Complete(false) => Result.Complete(false)
case Result(value, complete) =>
accumulator(
thunks,
a.flatMap(aValue => Result(value && aValue, complete)),
)
case _ => accumulator(thunks, Result.Incomplete)
case Nil => a
override def explain(
xs: List[Expression[_]],
)(using Factual): MaybeVector[Explanation] =
val caseVectors = xs.map(x => (x.getThunk, x.explain))
MaybeVector.vectorizeListTuple2(
cases => explainRecurse(cases, Explanation.Operation(List())),
caseVectors,
)
@annotation.tailrec
private def explainRecurse(
cases: List[(Thunk[Result[_]], Explanation)],
explanation: Explanation,
): Explanation = cases match
case (x, xExp) :: next =>
x.get match
case Result.Complete(false) =>
Explanation.opWithInclusiveChildren(xExp)
case _ =>
explainRecurse(
next,
Explanation.Operation(explanation.children :+ List(xExp)),
)
case Nil => explanation

View file

@ -0,0 +1,107 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.{Explanation, Expression, FactDictionary, Factual}
import gov.irs.factgraph.operators.ReduceOperator
import gov.irs.factgraph.monads.{MaybeVector, Result, Thunk}
object Any extends CompNodeFactory:
override val Key: String = "Any"
private val operator = AnyOperator()
def apply(nodes: Seq[BooleanNode]): BooleanNode =
BooleanNode(Expression.Reduce(nodes.map(_.expr).toList, operator))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val conditions = CompNode.getConfigChildNodes(e)
if (conditions.forall(_.isInstanceOf[BooleanNode]))
this(conditions.asInstanceOf[Seq[BooleanNode]])
else
throw new UnsupportedOperationException(
"all children of <Any> must be BooleanNodes",
)
private final class AnyOperator extends ReduceOperator[Boolean]:
// $COVERAGE-OFF$
override protected def reduce(x: Boolean, y: Boolean): Boolean = ???
// $COVERAGE-ON$
override def apply(
head: Result[Boolean],
tail: List[Thunk[Result[Boolean]]],
): Result[Boolean] = head match
case Result.Complete(true) => Result.Complete(true)
case _ => accumulator(tail, head)
@scala.annotation.tailrec
private def accumulator(
thunks: List[Thunk[Result[Boolean]]],
a: Result[Boolean],
): Result[Boolean] = thunks match
case thunk :: thunks =>
val result = thunk.get
result match
case Result.Complete(true) => Result.Complete(true)
case Result.Placeholder(true) =>
accumulator(thunks, Result.Placeholder(true))
case Result.Incomplete =>
if (a == Result.Placeholder(true)) then
// any set containing Placeholder(true) returns Placeholder(true),
// unless it includes a Complete(true) result
accumulator(thunks, Result.Placeholder(true))
else
// otherwise, any set containing Incomplete returns Incomplete
accumulator(thunks, Result.Incomplete)
case _ => // Complete(false) or Placeholder(false)
if (a.complete) then
// if the accumulated result is Complete(false), we'll use the new
// result, which is either Complete(false) or Placeholder(false)
//
// ACCUMULATED NEW RESULT
// Complete(false) + Complete(false) = Complete(false)
// Complete(false) + Placeholder(false) = Placeholder(false)
accumulator(thunks, result)
else
// otherwise, we'll use the accumulated result, which is either
// Placeholder(true), Placeholder(false), or Incomplete
//
// ACCUMULATED NEW RESULT
// Placeholder(true) + Complete(false) = Placeholder(true)
// Placeholder(true) + Placeholder(false) = Placeholder(true)
// Placeholder(false) + Complete(false) = Placeholder(false)
// Placeholder(false) + Placeholder(false) = Placeholder(false)
// Incomplete + Complete(false) = Incomplete
// Incomplete + Placeholder(false) = Incomplete
accumulator(thunks, a)
case Nil => a
override def explain(
xs: List[Expression[_]],
)(using Factual): MaybeVector[Explanation] =
val caseVectors = xs.map(x => (x.getThunk, x.explain))
MaybeVector.vectorizeListTuple2(
cases => explainRecurse(cases, Explanation.Operation(List())),
caseVectors,
)
@annotation.tailrec
private def explainRecurse(
cases: List[(Thunk[Result[_]], Explanation)],
explanation: Explanation,
): Explanation = cases match
case (x, xExp) :: next =>
x.get match
case Result.Complete(true) =>
Explanation.opWithInclusiveChildren(xExp)
case _ =>
explainRecurse(
next,
Explanation.Operation(explanation.children :+ List(xExp)),
)
case Nil => explanation

View file

@ -0,0 +1,53 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.UnaryOperator
import gov.irs.factgraph.types.Rational
object AsDecimalString extends CompNodeFactory:
override val Key: String = "AsDecimalString"
private val defaultScale = 2
private val defaultScaleAsString = defaultScale.toString()
private val defaultOperator = RationalAsDecimalString(defaultScale)
def apply(node: CompNode, scale: Int): StringNode =
val operator =
if (scale == defaultScale) defaultOperator
else RationalAsDecimalString(scale)
node match
case node: RationalNode =>
StringNode(
Expression.Unary(
node.expr,
operator,
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot execute AsDecimalString on a ${node.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
CompNode.getConfigChildNode(e) match
case x: RationalNode =>
this(
x,
e.getOptionValue(CommonOptionConfigTraits.SCALE)
.getOrElse(defaultScaleAsString)
.toIntOption
.get,
)
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: ${e.typeName}",
)
private final class RationalAsDecimalString(val scale: Int) extends UnaryOperator[String, Rational]:
override protected def operation(x: Rational): String =
(BigDecimal(x.numerator) / BigDecimal(x.denominator))
.setScale(scale, BigDecimal.RoundingMode.HALF_UP)
.toString()

View file

@ -0,0 +1,86 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.UnaryOperator
import gov.irs.factgraph.types.{Dollar, Enum, EmailAddress, Ein, Tin}
object AsString extends CompNodeFactory:
override val Key: String = "AsString"
private val enumOperator = EnumAsStringOperator()
private val emailAddressOperator = EmailAsStringOperator()
private val dollarOperator = DollarAsStringOperator()
private val einOperator = EinAsStringOperator()
private val tinOperator = TinAsStringOperator()
def apply(node: CompNode): StringNode =
node match
case node: EnumNode =>
StringNode(
Expression.Unary(
node.expr,
enumOperator,
),
)
case node: EmailAddressNode =>
StringNode(
Expression.Unary(
node.expr,
emailAddressOperator,
),
)
case node: DollarNode =>
StringNode(
Expression.Unary(
node.expr,
dollarOperator,
),
)
case node: EinNode =>
StringNode(
Expression.Unary(
node.expr,
einOperator,
),
)
case node: TinNode =>
StringNode(
Expression.Unary(
node.expr,
tinOperator,
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot execute AsString on a ${node.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
CompNode.getConfigChildNode(e) match
case x: EnumNode => this(x)
case x: EmailAddressNode => this(x)
case x: DollarNode => this(x)
case x: EinNode => this(x)
case x: TinNode => this(x)
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: ${e.typeName}",
)
private final class EnumAsStringOperator extends UnaryOperator[String, Enum]:
override protected def operation(x: Enum): String = x.getValue()
private final class EmailAsStringOperator extends UnaryOperator[String, EmailAddress]:
override protected def operation(x: EmailAddress): String = x.toString()
private final class DollarAsStringOperator extends UnaryOperator[String, Dollar]:
override protected def operation(x: Dollar): String = x.toString()
private final class EinAsStringOperator extends UnaryOperator[String, Ein]:
override protected def operation(x: Ein): String = x.toString()
private final class TinAsStringOperator extends UnaryOperator[String, Tin]:
override protected def operation(x: Tin): String = x.toString()

View file

@ -0,0 +1,56 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.monads.{MaybeVector, Result}
import gov.irs.factgraph.types.BankAccount
final case class BankAccountNode(expr: Expression[BankAccount]) extends CompNode:
type Value = BankAccount
override def ValueClass = classOf[BankAccount]
override private[compnodes] def fromExpression(
expr: Expression[BankAccount],
): CompNode =
BankAccountNode(expr)
override def extract(key: PathItem): Option[CompNode] =
key match
case PathItem.Child(Symbol("accountType")) =>
Some(
StringNode(
Expression.Extract((x: Result[BankAccount]) => x.map(y => y.accountType)),
),
)
case PathItem.Child(Symbol("routingNumber")) =>
Some(
StringNode(
Expression.Extract((x: Result[BankAccount]) => x.map(y => y.routingNumber)),
),
)
case PathItem.Child(Symbol("accountNumber")) =>
Some(
StringNode(
Expression.Extract((x: Result[BankAccount]) => x.map(y => y.accountNumber)),
),
)
case _ => None
object BankAccountNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "BankAccount"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new BankAccountNode(
Expression.Writable(classOf[BankAccount]),
)
def apply(value: BankAccount): BankAccountNode = new BankAccountNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
throw new NotImplementedError("BankAccoutNode.fromDerivedConfig")

View file

@ -0,0 +1,43 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CompNodeConfigTrait, WritableConfigTrait}
final case class BooleanNode(expr: Expression[Boolean]) extends CompNode:
type Value = Boolean
override def ValueClass =
classOf[java.lang.Boolean].asInstanceOf[Class[Boolean]]
override private[compnodes] def fromExpression(
expr: Expression[Boolean],
): CompNode =
BooleanNode(expr)
object BooleanNode extends WritableNodeFactory:
override val Key: String = "Boolean"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new BooleanNode(
Expression.Writable(
classOf[java.lang.Boolean].asInstanceOf[Class[Boolean]],
),
)
def apply(value: Boolean): BooleanNode = if (value) True.node else False.node
object True extends CompNodeFactory:
override val Key: String = "True"
val node: BooleanNode = new BooleanNode(Expression.Constant(Some(true)))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode = node
object False extends CompNodeFactory:
override val Key: String = "False"
val node: BooleanNode = new BooleanNode(Expression.Constant(Some(false)))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode = node

View file

@ -0,0 +1,60 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.WritableConfigTrait
import gov.irs.factgraph.types.CollectionItem
import scala.scalajs.js.annotation.JSExport
final case class CollectionItemNode(
expr: Expression[CollectionItem],
alias: Option[Path],
) extends CompNode:
type Value = CollectionItem
override def ValueClass = classOf[CollectionItem]
@JSExport
def getAlias() = alias
override private[compnodes] def switch(
cases: List[(BooleanNode, CompNode)],
): CompNode =
val aliasesMatch = cases.forall(
_._2.asInstanceOf[CollectionItemNode].alias == alias,
)
if (!aliasesMatch)
throw new UnsupportedOperationException(
"collection items in a <Switch> must reference the same collection",
)
CollectionItemNode(
Expression.Switch(
cases
.asInstanceOf[List[(BooleanNode, CollectionItemNode)]]
.map((b, a) => (b.expr, a.expr)),
),
alias,
)
override private[compnodes] def dependency(path: Path): CompNode =
val newAlias = alias match
case Some(_) => alias
case None => Some(path)
CollectionItemNode(Expression.Dependency(path), newAlias)
override private[factgraph] def fromExpression(
expr: Expression[CollectionItem],
): CompNode =
CollectionItemNode(expr, alias)
object CollectionItemNode extends WritableNodeFactory:
override val Key: String = "CollectionItem"
override def fromWritableConfig(
e: WritableConfigTrait,
)(using Factual)(using FactDictionary): CompNode =
new CollectionItemNode(
Expression.Writable(classOf[CollectionItem]),
Some(Path(e.collectionItemAlias.get)),
)

View file

@ -0,0 +1,71 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.*
import gov.irs.factgraph.definitions.fact.WritableConfigTrait
import gov.irs.factgraph.types.{Collection, CollectionItem}
final case class CollectionNode(
expr: Expression[Collection],
alias: Option[Path],
) extends CompNode:
type Value = Collection
override def ValueClass = classOf[Collection]
override private[compnodes] def switch(
cases: List[(BooleanNode, CompNode)],
): CompNode =
val aliasesMatch = cases.forall(
_._2.asInstanceOf[CollectionNode].alias == alias,
)
if (!aliasesMatch)
throw new UnsupportedOperationException(
"collections in a <Switch> must reference the same collection",
)
CollectionNode(
Expression.Switch(
cases
.asInstanceOf[List[(BooleanNode, CollectionNode)]]
.map((b, a) => (b.expr, a.expr)),
),
alias,
)
override private[compnodes] def dependency(path: Path): CompNode =
val newAlias = alias match
case Some(_) => alias
case None => Some(path)
CollectionNode(Expression.Dependency(path), newAlias)
override private[factgraph] def fromExpression(
expr: Expression[Collection],
): CompNode =
throw new UnsupportedOperationException(
"cannot create a Collection from an expression",
)
override def extract(key: PathItem): Option[CompNode] =
key match
case PathItem.Member(id) =>
Some(
CollectionItemNode(
Expression.Constant(Some(CollectionItem(id))),
None,
),
)
case PathItem.Unknown =>
Some(CollectionItemNode(Expression.Constant(None), None))
case _ => None
object CollectionNode extends WritableNodeFactory:
override val Key: String = "Collection"
override def fromWritableConfig(
e: WritableConfigTrait,
)(using Factual)(using FactDictionary): CompNode =
new CollectionNode(
Expression.Writable(classOf[Collection]),
None,
)

View file

@ -0,0 +1,31 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.{ReduceOperator, UnaryOperator}
import gov.irs.factgraph.types.Collection
object CollectionSize extends CompNodeFactory:
override val Key: String = "CollectionSize"
private val operator = CollectionSizeOperator()
def apply(node: CollectionNode): IntNode =
IntNode(
Expression.Unary(
node.expr,
operator,
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
CompNode.getConfigChildNode(e) match
case x: CollectionNode => this(x)
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: ${e.typeName}",
)
private final class CollectionSizeOperator extends UnaryOperator[Int, Collection]:
override protected def operation(x: Collection): Int =
x.items.length

View file

@ -0,0 +1,73 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.operators.AggregateOperator
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.types.*
import scala.annotation.unused
// Sum is used across a collection. If you're trying to add
// a number of dependencies, use the `Add` node.
object CollectionSum extends CompNodeFactory:
override val Key: String = "CollectionSum"
def apply(node: CompNode): CompNode =
node match
case node: IntNode =>
IntNode(
Expression.Aggregate(
node.expr,
summon[SumOperator[Int]],
),
)
case node: DollarNode =>
DollarNode(
Expression.Aggregate(
node.expr,
summon[SumOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Aggregate(
node.expr,
summon[SumOperator[Rational]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot sum a ${node.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(CompNode.getConfigChildNode(e))
private final class SumOperator[A: Numeric] extends AggregateOperator[A, A]:
override def apply(vect: MaybeVector[Thunk[Result[A]]]): Result[A] =
accumulator(vect.toList, Result(Numeric[A].zero, vect.complete))
@scala.annotation.tailrec
private def accumulator(
thunks: List[Thunk[Result[A]]],
a: Result[A],
): Result[A] = thunks match
case thunk :: thunks =>
thunk.get match
case Result(value, complete) =>
val sum = Numeric[A].plus(value, a.get)
accumulator(
thunks,
Result(sum, complete && a.complete),
)
case _ => Result.Incomplete
case Nil => a
@unused
private object SumOperator:
implicit val intOperator: SumOperator[Int] = SumOperator[Int]
implicit val dollarOperator: SumOperator[Dollar] = SumOperator[Dollar]
implicit val rationalOperator: SumOperator[Rational] = SumOperator[Rational]
// implicit def numericOperator[A: Numeric]: SumOperator[A] = SumOperator[A]

View file

@ -0,0 +1,181 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.*
import gov.irs.factgraph.definitions.*
import gov.irs.factgraph.definitions.fact.{CompNodeConfigTrait, LimitConfigTrait}
import gov.irs.factgraph.persisters.Persister
import gov.irs.factgraph.util.Seq.itemsHaveSameRuntimeClass
import scala.collection.mutable
import gov.irs.factgraph.limits.Limit
import transformations.TruncateNameForMeF
trait CompNode:
type Value
/** Runtime class of associated value */
def ValueClass: Class[Value]
val expr: Expression[Value]
export expr.{get, getThunk, explain, set, delete, isWritable}
private[compnodes] def fromExpression(expr: Expression[Value]): CompNode
private[compnodes] def switch(
cases: List[(BooleanNode, CompNode)],
): CompNode =
if (!itemsHaveSameRuntimeClass(cases.map(_._2)))
throw new UnsupportedOperationException(
"cannot switch between nodes of different types",
)
fromExpression(
Expression.Switch(
cases.map((b, a) => (b.expr, a.expr.asInstanceOf[Expression[Value]])),
),
)
private[compnodes] def dependency(path: Path): CompNode = fromExpression(
Expression.Dependency(path),
)
def extract(key: PathItem): Option[CompNode] = None
def getIntrinsicLimits()(using Factual): Seq[Limit] = Seq.empty
object CompNode:
private val defaultFactories: Seq[CompNodeFactory] = List(
// Constant nodes
BooleanNode.False,
BooleanNode.True,
DollarNode,
IntNode,
DaysNode,
RationalNode,
DayNode,
StringNode,
TinNode,
EinNode,
EmailAddressNode,
AddressNode,
BankAccountNode,
EnumNode,
MultiEnumNode,
PhoneNumberNode,
// Operation nodes
Add,
All,
Any,
Today,
AsString,
AsDecimalString,
CollectionSize,
Count,
Dependency,
Divide,
EnumOptionsContains,
EnumOptionsSize,
EnumOptionsNode,
Equal,
Filter,
FirstNCollectionItems,
Find,
GreaterOf,
GreaterThan,
GreaterThanOrEqual,
IndexOf,
IsComplete,
Length,
LesserOf,
LessThan,
LessThanOrEqual,
Maximum,
Minimum,
Multiply,
Not,
NotEqual,
Paste,
Placeholder,
Regex,
Round,
RoundToInt,
StepwiseMultiply,
StripChars,
Subtract,
CollectionSum,
Switch,
Trim,
ToUpper,
TruncateCents,
TruncateNameForMeF,
)
private val factories = mutable.Map(defaultFactories.map(_.asTuple)*)
def register(f: CompNodeFactory): Unit = factories.addOne(f.asTuple)
def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val factory = factories.getOrElse(
e.typeName,
throw new UnsupportedOperationException(
s"${e.typeName} is not a registered CompNode",
),
)
factory.fromDerivedConfig(e)
def getConfigChildNode(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val children = e.children
.map(fromDerivedConfig(_))
children match
case child :: Nil => child
case _ =>
throw new IllegalArgumentException(
s"<${e.typeName}> must have exactly one child node: $e",
)
def getConfigChildNodes(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): Seq[CompNode] =
val children = e.children
.map(fromDerivedConfig(_))
children match
case Nil =>
throw new IllegalArgumentException(
s"<${e.typeName}> must have at least one child node: $e",
)
case _ => children.toSeq
def getConfigChildNode(e: CompNodeConfigTrait, label: String)(using Factual)(using
FactDictionary,
): CompNode =
val children = e.children
.filter(x => x.typeName == label)
.flatMap(_.children)
.map(fromDerivedConfig(_))
children match
case child :: Nil => child
case _ =>
throw new IllegalArgumentException(
s"<${e.typeName}> must have exactly one <$label>: $e",
)
def getConfigChildNodes(e: CompNodeConfigTrait, label: String)(using Factual)(using
FactDictionary,
): Seq[CompNode] =
val children = e.children
.filter(x => x.typeName == label)
.flatMap(_.children)
.map(fromDerivedConfig(_))
children match
case Nil =>
throw new IllegalArgumentException(
s"<${e.typeName}> must have at least one <$label>: $e",
)
case _ => children.toSeq

View file

@ -0,0 +1,11 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
trait CompNodeFactory:
val Key: String
def asTuple: (String, CompNodeFactory) = (Key, this)
def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode

View file

@ -0,0 +1,44 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.operators.AggregateOperator
import gov.irs.factgraph.monads.*
object Count extends CompNodeFactory:
override val Key: String = "Count"
private val operator = CountOperator()
def apply(bool: BooleanNode): CompNode =
IntNode(Expression.Aggregate(bool.expr, operator))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
CompNode.getConfigChildNode(e) match
case x: BooleanNode => this(x)
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: $e",
)
private final class CountOperator extends AggregateOperator[Int, Boolean]:
override def apply(vect: MaybeVector[Thunk[Result[Boolean]]]): Result[Int] =
accumulator(vect.toList, Result(0, vect.complete))
@scala.annotation.tailrec
private def accumulator(
thunks: List[Thunk[Result[Boolean]]],
a: Result[Int],
): Result[Int] = thunks match
case thunk :: thunks =>
thunk.get match
case Result(bool, complete) =>
val count = if bool then a.get + 1 else a.get
accumulator(
thunks,
Result(count, complete && a.complete),
)
case _ => Result.Incomplete
case Nil => a

View file

@ -0,0 +1,50 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.monads.{Result}
import gov.irs.factgraph.types.{CollectionItem, Day}
final case class DayNode(expr: Expression[Day]) extends CompNode:
type Value = Day
override def ValueClass = classOf[Day];
override private[compnodes] def fromExpression(
expr: Expression[Day],
): CompNode =
DayNode(expr)
override def extract(key: PathItem): Option[CompNode] =
key match
case PathItem.Child(Symbol("year")) =>
Some(
IntNode(Expression.Extract((x: Result[Day]) => x.map(y => y.year))),
)
case PathItem.Child(Symbol("month")) =>
Some(
IntNode(Expression.Extract((x: Result[Day]) => x.map(y => y.month))),
)
case PathItem.Child(Symbol("day")) =>
Some(
IntNode(Expression.Extract((x: Result[Day]) => x.map(y => y.day))),
)
case _ => None
object DayNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "Day"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new DayNode(
Expression.Writable(classOf[Day]),
)
def apply(value: Day): DayNode = new DayNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(Day(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,33 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.types.Days
final case class DaysNode(expr: Expression[Days]) extends CompNode:
type Value = Days
override def ValueClass = classOf[java.lang.Integer].asInstanceOf[Class[Days]]
override private[compnodes] def fromExpression(
expr: Expression[Days],
): CompNode =
DaysNode(expr)
object DaysNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "Days"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new DaysNode(
Expression.Writable(classOf[java.lang.Integer].asInstanceOf[Class[Days]]),
)
def apply(value: Days): DaysNode = new DaysNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(Days(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,21 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.monads.Result
object Dependency extends CompNodeFactory:
override val Key: String = "Dependency"
def apply(path: Path)(using fact: Factual): CompNode =
fact(path)(0) match
case Result.Complete(fact) => fact.value.dependency(path)
case _ =>
throw new IllegalArgumentException(
s"cannot find fact at path '$path' from '${fact.path}'",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(Path(e.getOptionValue(CommonOptionConfigTraits.PATH).get))

View file

@ -0,0 +1,171 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.{*, given}
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.{BinaryOperator, ReduceOperator}
import gov.irs.factgraph.util.Seq.itemsHaveSameRuntimeClass
import scala.annotation.unused
object Divide extends CompNodeFactory:
override val Key: String = "Divide"
def apply(nodes: Seq[CompNode]): CompNode =
if (itemsHaveSameRuntimeClass(nodes))
reduceDivide(nodes)
else
nodes.reduceLeft(binaryDivide)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val dividend = CompNode.getConfigChildNode(e, "Dividend")
val divisors = CompNode.getConfigChildNodes(e, "Divisors")
this(dividend +: divisors)
private def reduceDivide(nodes: Seq[CompNode]): CompNode =
nodes.head match
case node: IntNode =>
nodes.reduceLeft(binaryDivide)
case node: DollarNode =>
DollarNode(
Expression.Reduce(
nodes.asInstanceOf[List[DollarNode]].map(_.expr),
summon[DivideReduceOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Reduce(
nodes.asInstanceOf[List[RationalNode]].map(_.expr),
summon[DivideReduceOperator[Rational]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot Divide a ${nodes.head.getClass.getName}",
)
private def binaryDivide(lhs: CompNode, rhs: CompNode): CompNode =
(lhs, rhs).match
case (lhs: IntNode, rhs: IntNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Rational, Int, Int]],
),
)
case (lhs: DollarNode, rhs: DollarNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Dollar, Dollar, Dollar]],
),
)
case (lhs: RationalNode, rhs: RationalNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Rational, Rational, Rational]],
),
)
case (lhs: IntNode, rhs: DollarNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Dollar, Int, Dollar]],
),
)
case (lhs: DollarNode, rhs: IntNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Dollar, Dollar, Int]],
),
)
case (lhs: IntNode, rhs: RationalNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Rational, Int, Rational]],
),
)
case (lhs: RationalNode, rhs: IntNode) =>
RationalNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Rational, Rational, Int]],
),
)
case (lhs: RationalNode, rhs: DollarNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Dollar, Rational, Dollar]],
),
)
case (lhs: DollarNode, rhs: RationalNode) =>
DollarNode(
Expression.Binary(
lhs.expr,
rhs.expr,
summon[DivideBinaryOperator[Dollar, Dollar, Rational]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot Divide a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
)
private final class DivideReduceOperator[A: Fractional] extends ReduceOperator[A]:
override protected def reduce(x: A, y: A): A = Fractional[A].div(x, y)
@unused
private object DivideReduceOperator:
implicit val dollarOperator: DivideReduceOperator[Dollar] =
DivideReduceOperator[Dollar]
implicit val rationalOperator: DivideReduceOperator[Rational] =
DivideReduceOperator[Rational]
// implicit def fractionalOperator[A: Fractional]: DivideReduceOperator[A] =
// DivideReduceOperator[A]
private trait DivideBinaryOperator[A, L, R] extends BinaryOperator[A, L, R]
@unused
private object DivideBinaryOperator:
implicit val intIntOperator: DivideBinaryOperator[Rational, Int, Int] =
(lhs: Int, rhs: Int) => Rational(lhs, rhs)
implicit val dollarDollarOperator: DivideBinaryOperator[Dollar, Dollar, Dollar] =
(lhs: Dollar, rhs: Dollar) => lhs / rhs
implicit val rationalRationalOperator: DivideBinaryOperator[Rational, Rational, Rational] =
(lhs: Rational, rhs: Rational) => lhs / rhs
implicit val intDollarOperator: DivideBinaryOperator[Dollar, Int, Dollar] =
(lhs: Int, rhs: Dollar) => Fractional[Dollar].div(lhs, rhs)
implicit val dollarIntOperator: DivideBinaryOperator[Dollar, Dollar, Int] =
(lhs: Dollar, rhs: Int) => Fractional[Dollar].div(lhs, rhs)
implicit val intRationalOperator: DivideBinaryOperator[Rational, Int, Rational] =
(lhs: Int, rhs: Rational) => Fractional[Rational].div(lhs, rhs)
implicit val rationalIntOperator: DivideBinaryOperator[Rational, Rational, Int] =
(lhs: Rational, rhs: Int) => Fractional[Rational].div(lhs, rhs)
implicit val dollarRationalOperator: DivideBinaryOperator[Dollar, Dollar, Rational] =
(lhs: Dollar, rhs: Rational) =>
Fractional[Dollar].div(
Numeric[Dollar].times(lhs, rhs.denominator),
rhs.numerator,
)
implicit val rationalDollarOperator: DivideBinaryOperator[Dollar, Rational, Dollar] =
(lhs: Rational, rhs: Dollar) =>
Fractional[Dollar].div(
lhs.numerator,
Numeric[Dollar].times(rhs, lhs.denominator),
)

View file

@ -0,0 +1,33 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.types.Dollar
final case class DollarNode(expr: Expression[Dollar]) extends CompNode:
type Value = Dollar
override def ValueClass = classOf[BigDecimal].asInstanceOf[Class[Dollar]]
override private[compnodes] def fromExpression(
expr: Expression[Dollar],
): CompNode =
DollarNode(expr)
object DollarNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "Dollar"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new DollarNode(
Expression.Writable(classOf[BigDecimal].asInstanceOf[Class[Dollar]]),
)
def apply(value: Dollar): DollarNode = new DollarNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(Dollar(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,38 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.Expression
import gov.irs.factgraph.Factual
import gov.irs.factgraph.Path
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.types.Ein
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.PathItem
import gov.irs.factgraph.monads.Result
final case class EinNode(expr: Expression[Ein]) extends CompNode:
type Value = Ein
override def ValueClass = classOf[Ein]
override private[compnodes] def fromExpression(
expr: Expression[Ein],
): CompNode =
EinNode(expr)
object EinNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "EIN"
override def fromWritableConfig(e: WritableConfigTrait)(using
Factual,
)(using FactDictionary): CompNode =
new EinNode(
Expression.Writable(classOf[Ein]),
)
def apply(value: Ein): EinNode = new EinNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(Ein(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,33 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.types.EmailAddress
final case class EmailAddressNode(expr: Expression[EmailAddress]) extends CompNode:
type Value = EmailAddress
override def ValueClass = classOf[EmailAddress]
override private[compnodes] def fromExpression(
expr: Expression[EmailAddress],
): CompNode =
EmailAddressNode(expr)
object EmailAddressNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "EmailAddress"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new EmailAddressNode(
Expression.Writable(classOf[EmailAddress]),
)
def apply(value: EmailAddress): EmailAddressNode = new EmailAddressNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(EmailAddress(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,69 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.limits.Limit
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.limits.ContainsLimit
import gov.irs.factgraph.definitions.fact.LimitLevel
import gov.irs.factgraph.definitions.fact.CompNodeConfigElement
import gov.irs.factgraph.limits.LimitContext
final case class EnumNode(
expr: Expression[gov.irs.factgraph.types.Enum],
enumOptionsPath: String,
) extends CompNode:
type Value = gov.irs.factgraph.types.Enum
override def ValueClass = classOf[gov.irs.factgraph.types.Enum]
override private[compnodes] def fromExpression(
expr: Expression[gov.irs.factgraph.types.Enum],
): CompNode =
EnumNode(expr, enumOptionsPath)
object EnumNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "Enum"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val dictionary = summon[FactDictionary]
val enumOptionsPath = e.options.find(x => x.name == "optionsPath")
if (enumOptionsPath == None) {
throw new IllegalArgumentException(
s"Enum must contain ${CommonOptionConfigTraits.ENUM_OPTIONS_PATH}",
)
}
new EnumNode(
Expression.Writable(classOf[gov.irs.factgraph.types.Enum]),
enumOptionsPath.get.value,
)
def apply(value: gov.irs.factgraph.types.Enum): EnumNode = new EnumNode(
Expression.Constant(Some(value)),
value.enumOptionsPath,
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val dictionary = summon[FactDictionary]
val enumOptionsPath =
e.getOptionValue(CommonOptionConfigTraits.ENUM_OPTIONS_PATH)
val value = e.getOptionValue(CommonOptionConfigTraits.VALUE)
if (value == None) {
throw new IllegalArgumentException(
s"Enum must contain ${CommonOptionConfigTraits.VALUE}",
)
}
if (enumOptionsPath == None) {
throw new IllegalArgumentException(
s"Enum must contain ${CommonOptionConfigTraits.ENUM_OPTIONS_PATH}",
)
}
this(
gov.irs.factgraph.types.Enum
.apply(value.get, enumOptionsPath.get),
)

View file

@ -0,0 +1,67 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.monads.{MaybeVector, Result, Thunk}
import gov.irs.factgraph.operators.{AggregateOperator, BinaryOperator, UnaryOperator}
object EnumOptionsContains extends CompNodeFactory:
override val Key: String = "EnumOptionsContains"
private val enumOperator = EnumContainsOperator()
private val multiEnumOperator = MultiEnumContainsOperator()
def apply(node: CompNode, value: EnumNode): BooleanNode =
node match
case node: EnumOptionsNode =>
BooleanNode(
Expression.Binary(
node.expr,
value.expr,
enumOperator,
),
)
case node: MultiEnumNode =>
BooleanNode(
Expression.Binary(
node.expr,
value.expr,
multiEnumOperator,
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val lhs = CompNode.getConfigChildNode(e, "Options")
val rhs = CompNode.getConfigChildNode(e, "Value")
val lhsIsEnumOptions = lhs.isInstanceOf[EnumOptionsNode]
val lhsIsMultiEnum = lhs.isInstanceOf[MultiEnumNode]
if ((!lhsIsEnumOptions && !lhsIsMultiEnum) || !rhs.isInstanceOf[EnumNode])
throw new IllegalArgumentException(
"Options should be EnumOptions or MultiEnum and Value should be Enum node",
)
if (lhsIsEnumOptions)
this(lhs.asInstanceOf[EnumOptionsNode], rhs.asInstanceOf[EnumNode])
else this(lhs.asInstanceOf[MultiEnumNode], rhs.asInstanceOf[EnumNode])
private final class EnumContainsOperator() extends BinaryOperator[Boolean, List[String], gov.irs.factgraph.types.Enum]:
override protected def operation(
options: List[String],
enumm: gov.irs.factgraph.types.Enum,
): Boolean =
enumm.value match
case Some(value) => options.contains(value)
case None => true // Not having picked an option is always valid
private final class MultiEnumContainsOperator()
extends BinaryOperator[
Boolean,
gov.irs.factgraph.types.MultiEnum,
gov.irs.factgraph.types.Enum,
]:
override protected def operation(
options: gov.irs.factgraph.types.MultiEnum,
enumm: gov.irs.factgraph.types.Enum,
): Boolean =
enumm.value match
case Some(value) => options.values.contains(value)
case None => true // Not having picked an option is always valid

View file

@ -0,0 +1,70 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.operators.AggregateOperator
import gov.irs.factgraph.monads.MaybeVector
import gov.irs.factgraph.monads.Result
import gov.irs.factgraph.monads.Thunk
final case class EnumOptionsNode(expr: Expression[List[String]]) extends CompNode:
type Value = List[String]
// below is equivalent to `Nil.getClass().asInstanceOf[Class[List[String]]]` due to scala List implementation
override def ValueClass =
List[String]().getClass().asInstanceOf[Class[List[String]]]
override private[compnodes] def fromExpression(
expr: Expression[List[String]],
): CompNode =
EnumOptionsNode(expr)
object EnumOptionsNode extends CompNodeFactory:
override val Key: String = "EnumOptions"
def apply(
options: List[(BooleanNode, StringNode)],
)(using Factual): CompNode =
val allowedOpts = Expression.ConditionalList(
options.map(o => (o._1.expr, o._2.expr)),
)
EnumOptionsNode(allowedOpts)
def apply(options: List[String]): CompNode =
EnumOptionsNode(Expression.Constant(Some(options)))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
try {
val conditions = for {
c <- e.children.filter(x => (x.typeName == "EnumOption" || x.typeName == "String"))
} yield (
c.typeName match
case "String" =>
(
BooleanNode(true),
StringNode(
Expression.Constant(
c.getOptionValue(CommonOptionConfigTraits.VALUE),
),
),
)
case "EnumOption" =>
(
CompNode
.getConfigChildNode(c, "Condition")
.asInstanceOf[BooleanNode],
CompNode.getConfigChildNode(c, "Value").asInstanceOf[StringNode],
)
)
if (conditions.isEmpty) {
throw new IllegalArgumentException(
s"EnumOptions must have at least one child node: $e",
)
}
this(conditions.toList)
} catch {
case e: ClassCastException =>
throw new UnsupportedOperationException(
s"Condition must be boolean: $e",
)
}

View file

@ -0,0 +1,31 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.monads.{MaybeVector, Result, Thunk}
import gov.irs.factgraph.operators.{AggregateOperator, BinaryOperator, UnaryOperator}
object EnumOptionsSize extends CompNodeFactory:
override val Key: String = "EnumOptionsSize"
private val operator = EnumOptionsSizeOperator()
def apply(node: EnumOptionsNode): IntNode =
IntNode(
Expression.Unary(
node.expr,
operator,
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
CompNode.getConfigChildNode(e) match
case x: EnumOptionsNode => this(x)
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: ${e.typeName}",
)
private final class EnumOptionsSizeOperator() extends UnaryOperator[Int, List[String]]:
override protected def operation(options: List[String]): Int =
options.length

View file

@ -0,0 +1,35 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.BinaryOperator
object Equal extends CompNodeFactory:
override val Key: String = "Equal"
private val operator = EqualOperator()
def apply(lhs: CompNode, rhs: CompNode): BooleanNode =
if (lhs.getClass != rhs.getClass)
throw new UnsupportedOperationException(
s"cannot compare a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
)
BooleanNode(
Expression.Binary(
lhs.expr,
rhs.expr,
operator,
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val lhs = CompNode.getConfigChildNode(e, "Left")
val rhs = CompNode.getConfigChildNode(e, "Right")
this(lhs, rhs)
private final class EqualOperator extends BinaryOperator[Boolean, Any, Any]:
override protected def operation(x: Any, y: Any): Boolean = x == y

View file

@ -0,0 +1,56 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.CollectOperator
import gov.irs.factgraph.types.{Collection, CollectionItem}
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
object Filter extends CompNodeFactory:
override val Key: String = "Filter"
private val operator = FilterOperator()
def apply(path: Path, cnBuilder: Factual ?=> BooleanNode)(using
fact: Factual,
): CompNode =
fact(path :+ PathItem.Wildcard)(0) match
case Result.Complete(collectionItem) =>
CollectionNode(
Expression
.Collect(path, cnBuilder(using collectionItem).expr, operator),
Some(path),
)
case _ =>
throw new IllegalArgumentException(
s"cannot find fact at path '$path' from '${fact.path}'",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val cnBuilder: Factual ?=> BooleanNode =
CompNode.getConfigChildNode(e) match
case node: BooleanNode => node
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: $e",
)
this(Path(e.getOptionValue(CommonOptionConfigTraits.PATH).get), cnBuilder)
private final class FilterOperator extends CollectOperator[Collection, Boolean]:
override def apply(
vect: MaybeVector[(CollectionItem, Thunk[Result[Boolean]])],
): Result[Collection] =
val (items, thunks) = vect.toVector.unzip
val results = thunks.map(_.get)
val bools = results.map(_.getOrElse(false))
val filteredIds = for {
i <- bools.indices if bools(i)
} yield items(i).id
val complete = vect.complete && results.forall(_.complete)
Result(Collection(filteredIds.toVector), complete)

View file

@ -0,0 +1,49 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.CollectOperator
import gov.irs.factgraph.types.CollectionItem
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
object Find extends CompNodeFactory:
override val Key: String = "Find"
private val operator = FindOperator()
def apply(path: Path, cnBuilder: Factual ?=> BooleanNode)(using
fact: Factual,
): CompNode =
fact(path :+ PathItem.Wildcard)(0) match
case Result.Complete(collectionItem) =>
CollectionItemNode(
Expression
.Collect(path, cnBuilder(using collectionItem).expr, operator),
Some(path),
)
case _ =>
throw new IllegalArgumentException(
s"cannot find fact at path '$path' from '${fact.path}'",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val cnBuilder: Factual ?=> BooleanNode =
CompNode.getConfigChildNode(e) match
case node: BooleanNode => node
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: $e",
)
this(Path(e.getOptionValue(CommonOptionConfigTraits.PATH).get), cnBuilder)
private final class FindOperator extends CollectOperator[CollectionItem, Boolean]:
override def apply(
vect: MaybeVector[(CollectionItem, Thunk[Result[Boolean]])],
): Result[CollectionItem] =
val item = vect.toVector.collectFirst {
case (item, thunk) if thunk.get.getOrElse(false) => item
}
Result(item)

View file

@ -0,0 +1,52 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.BinaryOperator
import gov.irs.factgraph.types.{Collection, CollectionItem}
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
object FirstNCollectionItems extends CompNodeFactory:
override val Key: String = "FirstNCollectionItems"
private val operator = FirstNCollectionItemsOperator()
def apply(collection: CollectionNode, count: IntNode): CollectionNode =
CollectionNode(
Expression.Binary(
collection.expr,
count.expr,
operator,
),
collection.alias,
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val collection = CompNode.getConfigChildNode(e, "Collection")
val count = CompNode.getConfigChildNode(e, "Count")
if (!collection.isInstanceOf[CollectionNode] || !count.isInstanceOf[IntNode])
throw new UnsupportedOperationException(
s"required to have a collection node and integer count node specified to use FirstNCollectionItems",
)
this(collection.asInstanceOf[CollectionNode], count.asInstanceOf[IntNode])
private final class FirstNCollectionItemsOperator extends BinaryOperator[Collection, Collection, Int]:
override def apply(
lhs: Result[Collection],
rhs: Thunk[Result[Int]],
): Result[Collection] =
if (lhs == Result.Incomplete || rhs.get == Result.Incomplete)
Result.Incomplete
else
(lhs, rhs.get) match
case (Result(collection, collComplete), Result(count, countComplete)) =>
Result(
Collection(collection.items.slice(0, count)),
collComplete && countComplete,
)
case _ => Result.Incomplete
override protected def operation(x: Collection, y: Int): Collection =
throw new Exception("shouldn't be calling this")

View file

@ -0,0 +1,74 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.ReduceOperator
import gov.irs.factgraph.util.Seq.itemsHaveSameRuntimeClass
import scala.annotation.unused
object GreaterOf extends CompNodeFactory:
override val Key: String = "GreaterOf"
def apply(nodes: Seq[CompNode]): CompNode =
if (!itemsHaveSameRuntimeClass(nodes))
throw new UnsupportedOperationException(
s"cannot compare nodes of different classes",
)
nodes.head match
case node: IntNode =>
IntNode(
Expression.Reduce(
nodes.asInstanceOf[List[IntNode]].map(_.expr),
summon[GreaterOfOperator[Int]],
),
)
case node: DollarNode =>
DollarNode(
Expression.Reduce(
nodes.asInstanceOf[List[DollarNode]].map(_.expr),
summon[GreaterOfOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Reduce(
nodes.asInstanceOf[List[RationalNode]].map(_.expr),
summon[GreaterOfOperator[Rational]],
),
)
case node: DayNode =>
DayNode(
Expression.Reduce(
nodes.asInstanceOf[List[DayNode]].map(_.expr),
summon[GreaterOfOperator[Day]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot compare a ${nodes.head.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(CompNode.getConfigChildNodes(e))
private final class GreaterOfOperator[A: Ordering] extends ReduceOperator[A]:
override protected def reduce(x: A, y: A): A =
if (Ordering[A].gteq(x, y)) x else y
@unused
private object GreaterOfOperator:
implicit val intOperator: GreaterOfOperator[Int] =
GreaterOfOperator[Int]
implicit val dollarOperator: GreaterOfOperator[Dollar] =
GreaterOfOperator[Dollar]
implicit val rationalOperator: GreaterOfOperator[Rational] =
GreaterOfOperator[Rational]
implicit val dayOperator: GreaterOfOperator[Day] =
GreaterOfOperator[Day]
// implicit def numericOperator[A: Numeric]: GreaterOfOperator[A] =
// GreaterOfOperator[A]

View file

@ -0,0 +1,68 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.BinaryOperator
import scala.annotation.unused
object GreaterThan extends CompNodeFactory:
override val Key: String = "GreaterThan"
def apply(lhs: CompNode, rhs: CompNode): BooleanNode =
BooleanNode(
(lhs, rhs).match
case (lhs: IntNode, rhs: IntNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanBinaryOperator[Int]],
)
case (lhs: DollarNode, rhs: DollarNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanBinaryOperator[Dollar]],
)
case (lhs: RationalNode, rhs: RationalNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanBinaryOperator[Rational]],
)
case (lhs: DayNode, rhs: DayNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanBinaryOperator[Day]],
)
case _ =>
throw new UnsupportedOperationException(
s"cannot compare a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val lhs = CompNode.getConfigChildNode(e, "Left")
val rhs = CompNode.getConfigChildNode(e, "Right")
this(lhs, rhs)
private final class GreaterThanBinaryOperator[A: Ordering] extends BinaryOperator[Boolean, A, A]:
override protected def operation(x: A, y: A): Boolean = Ordering[A].gt(x, y)
@unused
private object GreaterThanBinaryOperator:
implicit val intOperator: GreaterThanBinaryOperator[Int] =
GreaterThanBinaryOperator[Int]
implicit val dollarOperator: GreaterThanBinaryOperator[Dollar] =
GreaterThanBinaryOperator[Dollar]
implicit val rationalOperator: GreaterThanBinaryOperator[Rational] =
GreaterThanBinaryOperator[Rational]
implicit val dayOperator: GreaterThanBinaryOperator[Day] =
GreaterThanBinaryOperator[Day]
// implicit def numericOperator[A: Numeric]: GreaterThanBinaryOperator[A] =
// GreaterThanBinaryOperator[A]

View file

@ -0,0 +1,69 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.BinaryOperator
import scala.annotation.unused
object GreaterThanOrEqual extends CompNodeFactory:
override val Key: String = "GreaterThanOrEqual"
def apply(lhs: CompNode, rhs: CompNode): BooleanNode =
BooleanNode(
(lhs, rhs).match
case (lhs: IntNode, rhs: IntNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanOrEqualBinaryOperator[Int]],
)
case (lhs: DollarNode, rhs: DollarNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanOrEqualBinaryOperator[Dollar]],
)
case (lhs: RationalNode, rhs: RationalNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanOrEqualBinaryOperator[Rational]],
)
case (lhs: DayNode, rhs: DayNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[GreaterThanOrEqualBinaryOperator[Day]],
)
case _ =>
throw new UnsupportedOperationException(
s"cannot compare a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val lhs = CompNode.getConfigChildNode(e, "Left")
val rhs = CompNode.getConfigChildNode(e, "Right")
this(lhs, rhs)
private final class GreaterThanOrEqualBinaryOperator[A: Ordering] extends BinaryOperator[Boolean, A, A]:
override protected def operation(x: A, y: A): Boolean = Ordering[A].gteq(x, y)
@unused
private object GreaterThanOrEqualBinaryOperator:
implicit val intOperator: GreaterThanOrEqualBinaryOperator[Int] =
GreaterThanOrEqualBinaryOperator[Int]
implicit val dollarOperator: GreaterThanOrEqualBinaryOperator[Dollar] =
GreaterThanOrEqualBinaryOperator[Dollar]
implicit val rationalOperator: GreaterThanOrEqualBinaryOperator[Rational] =
GreaterThanOrEqualBinaryOperator[Rational]
implicit val dayOperator: GreaterThanOrEqualBinaryOperator[Day] =
GreaterThanOrEqualBinaryOperator[Day]
// implicit def numericOperator[A: Numeric]
// : GreaterThanOrEqualBinaryOperator[A] =
// GreaterThanOrEqualBinaryOperator[A]

View file

@ -0,0 +1,52 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.{BinaryOperator, CollectOperator}
import gov.irs.factgraph.types.{Collection, CollectionItem}
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
import java.util.UUID
object IndexOf extends CompNodeFactory:
override val Key: String = "IndexOf"
private val operator = IndexOfOperator()
def apply(collection: CollectionNode, index: IntNode): CollectionItemNode =
CollectionItemNode(
Expression.Binary(
collection.expr,
index.expr,
operator,
),
collection.alias,
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val collection = CompNode.getConfigChildNode(e, "Collection")
val index = CompNode.getConfigChildNode(e, "Index")
if (!collection.isInstanceOf[CollectionNode] || !index.isInstanceOf[IntNode])
throw new UnsupportedOperationException(
s"required to have a collection node and integer index node specified to use indexof",
)
this(collection.asInstanceOf[CollectionNode], index.asInstanceOf[IntNode])
private final class IndexOfOperator extends BinaryOperator[CollectionItem, Collection, Int]:
override def apply(
lhs: Result[Collection],
rhs: Thunk[Result[Int]],
): Result[CollectionItem] =
if (lhs == Result.Incomplete || rhs.get == Result.Incomplete) Result.Incomplete
else
val index = rhs.get.get
val collection = lhs.get
var potentialCollectionItem = collection.items.lift(index)
potentialCollectionItem match {
case Some(x) => Result.Complete(CollectionItem(x))
case None => Result.Incomplete
}
override protected def operation(x: Collection, y: Int): CollectionItem =
throw new Exception("shouldn't be calling this")

View file

@ -0,0 +1,30 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
final case class IntNode(expr: Expression[Int]) extends CompNode:
type Value = Int
override def ValueClass = classOf[java.lang.Number].asInstanceOf[Class[Int]]
override private[compnodes] def fromExpression(
expr: Expression[Int],
): CompNode =
IntNode(expr)
object IntNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "Int"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
new IntNode(
Expression.Writable(classOf[java.lang.Integer].asInstanceOf[Class[Int]]),
)
def apply(value: Int): IntNode = new IntNode(Expression.Constant(Some(value)))
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(e.getOptionValue(CommonOptionConfigTraits.VALUE).get.toInt)

View file

@ -0,0 +1,38 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.Expression
import gov.irs.factgraph.Factual
import gov.irs.factgraph.Path
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
import gov.irs.factgraph.types.IpPin
import gov.irs.factgraph.FactDictionary
import gov.irs.factgraph.PathItem
import gov.irs.factgraph.monads.Result
final case class IpPinNode(expr: Expression[IpPin]) extends CompNode:
type Value = IpPin
override def ValueClass = classOf[IpPin]
override private[compnodes] def fromExpression(
expr: Expression[IpPin],
): CompNode =
IpPinNode(expr)
object IpPinNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "IPPIN"
override def fromWritableConfig(e: WritableConfigTrait)(using
Factual,
)(using FactDictionary): CompNode =
new IpPinNode(
Expression.Writable(classOf[IpPin]),
)
def apply(value: IpPin): IpPinNode = new IpPinNode(
Expression.Constant(Some(value)),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(IpPin(e.getOptionValue(CommonOptionConfigTraits.VALUE).get))

View file

@ -0,0 +1,30 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.UnaryOperator
import gov.irs.factgraph.monads.Result
object IsComplete extends CompNodeFactory:
override val Key: String = "IsComplete"
private val operator = IsCompleteOperator()
def apply(x: CompNode): BooleanNode =
BooleanNode(
Expression.Unary(
x.expr,
operator,
),
)
override def fromDerivedConfig(
e: CompNodeConfigTrait,
)(using Factual)(using FactDictionary): CompNode =
this(CompNode.getConfigChildNode(e))
private final class IsCompleteOperator extends UnaryOperator[Boolean, Any]:
override protected def operation(x: Any): Boolean = ???
override def apply(x: Result[Any]): Result[Boolean] =
Result(x.complete, true)

View file

@ -0,0 +1,30 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.{ReduceOperator, UnaryOperator}
object Length extends CompNodeFactory:
override val Key: String = "Length"
private val operator = LengthOperator()
def apply(node: StringNode): IntNode =
IntNode(
Expression.Unary(
node.expr,
operator,
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
CompNode.getConfigChildNode(e) match
case x: StringNode => this(x)
case _ =>
throw new UnsupportedOperationException(
s"invalid child type: ${e.typeName}",
)
private final class LengthOperator() extends UnaryOperator[Int, String]:
override protected def operation(x: String): Int =
x.length

View file

@ -0,0 +1,68 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.BinaryOperator
import scala.annotation.unused
object LessThan extends CompNodeFactory:
override val Key: String = "LessThan"
def apply(lhs: CompNode, rhs: CompNode): BooleanNode =
BooleanNode(
(lhs, rhs).match
case (lhs: IntNode, rhs: IntNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanBinaryOperator[Int]],
)
case (lhs: DollarNode, rhs: DollarNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanBinaryOperator[Dollar]],
)
case (lhs: RationalNode, rhs: RationalNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanBinaryOperator[Rational]],
)
case (lhs: DayNode, rhs: DayNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanBinaryOperator[Day]],
)
case _ =>
throw new UnsupportedOperationException(
s"cannot compare a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val lhs = CompNode.getConfigChildNode(e, "Left")
val rhs = CompNode.getConfigChildNode(e, "Right")
this(lhs, rhs)
private final class LessThanBinaryOperator[A: Ordering] extends BinaryOperator[Boolean, A, A]:
override protected def operation(x: A, y: A): Boolean = Ordering[A].lt(x, y)
@unused
private object LessThanBinaryOperator:
implicit val intOperator: LessThanBinaryOperator[Int] =
LessThanBinaryOperator[Int]
implicit val dollarOperator: LessThanBinaryOperator[Dollar] =
LessThanBinaryOperator[Dollar]
implicit val rationalOperator: LessThanBinaryOperator[Rational] =
LessThanBinaryOperator[Rational]
implicit val dayOperator: LessThanBinaryOperator[Day] =
LessThanBinaryOperator[Day]
// implicit def numericOperator[A: Numeric]: LessThanBinaryOperator[A] =
// LessThanBinaryOperator[A]

View file

@ -0,0 +1,68 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.BinaryOperator
import scala.annotation.unused
object LessThanOrEqual extends CompNodeFactory:
override val Key: String = "LessThanOrEqual"
def apply(lhs: CompNode, rhs: CompNode): BooleanNode =
BooleanNode(
(lhs, rhs).match
case (lhs: IntNode, rhs: IntNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanOrEqualBinaryOperator[Int]],
)
case (lhs: DollarNode, rhs: DollarNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanOrEqualBinaryOperator[Dollar]],
)
case (lhs: RationalNode, rhs: RationalNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanOrEqualBinaryOperator[Rational]],
)
case (lhs: DayNode, rhs: DayNode) =>
Expression.Binary(
lhs.expr,
rhs.expr,
summon[LessThanOrEqualBinaryOperator[Day]],
)
case _ =>
throw new UnsupportedOperationException(
s"cannot compare a ${lhs.getClass.getName} and a ${rhs.getClass.getName}",
),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val lhs = CompNode.getConfigChildNode(e, "Left")
val rhs = CompNode.getConfigChildNode(e, "Right")
this(lhs, rhs)
private final class LessThanOrEqualBinaryOperator[A: Ordering] extends BinaryOperator[Boolean, A, A]:
override protected def operation(x: A, y: A): Boolean = Ordering[A].lteq(x, y)
@unused
private object LessThanOrEqualBinaryOperator:
implicit val intOperator: LessThanOrEqualBinaryOperator[Int] =
LessThanOrEqualBinaryOperator[Int]
implicit val dollarOperator: LessThanOrEqualBinaryOperator[Dollar] =
LessThanOrEqualBinaryOperator[Dollar]
implicit val rationalOperator: LessThanOrEqualBinaryOperator[Rational] =
LessThanOrEqualBinaryOperator[Rational]
implicit val dayOperator: LessThanOrEqualBinaryOperator[Day] =
LessThanOrEqualBinaryOperator[Day]
// implicit def numericOperator[A: Numeric]: LessThanOrEqualBinaryOperator[A] =
// LessThanOrEqualBinaryOperator[A]

View file

@ -0,0 +1,75 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.types.*
import gov.irs.factgraph.{Expression, FactDictionary, Factual}
import gov.irs.factgraph.definitions.fact.CompNodeConfigTrait
import gov.irs.factgraph.operators.ReduceOperator
import gov.irs.factgraph.util.Seq.itemsHaveSameRuntimeClass
import scala.annotation.unused
object LesserOf extends CompNodeFactory:
override val Key: String = "LesserOf"
def apply(nodes: Seq[CompNode]): CompNode =
if (!itemsHaveSameRuntimeClass(nodes))
throw new UnsupportedOperationException(
s"cannot compare nodes of different classes",
)
nodes.head match
case node: IntNode =>
IntNode(
Expression.Reduce(
nodes.asInstanceOf[List[IntNode]].map(_.expr),
summon[LesserOfOperator[Int]],
),
)
case node: DollarNode =>
DollarNode(
Expression.Reduce(
nodes.asInstanceOf[List[DollarNode]].map(_.expr),
summon[LesserOfOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Reduce(
nodes.asInstanceOf[List[RationalNode]].map(_.expr),
summon[LesserOfOperator[Rational]],
),
)
case node: DayNode =>
DayNode(
Expression.Reduce(
nodes.asInstanceOf[List[DayNode]].map(_.expr),
summon[LesserOfOperator[Day]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot compare a ${nodes.head.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(CompNode.getConfigChildNodes(e))
private final class LesserOfOperator[A: Ordering] extends ReduceOperator[A]:
override protected def reduce(x: A, y: A): A =
if (Ordering[A].lteq(x, y)) x else y
@unused
private object LesserOfOperator:
implicit val intOperator: LesserOfOperator[Int] =
LesserOfOperator[Int]
implicit val dollarOperator: LesserOfOperator[Dollar] =
LesserOfOperator[Dollar]
implicit val rationalOperator: LesserOfOperator[Rational] =
LesserOfOperator[Rational]
implicit val dayOperator: LesserOfOperator[Day] =
LesserOfOperator[Day]
// implicit def numericOperator[A: Numeric]: LesserOfOperator[A] =
// LesserOfOperator[A]

View file

@ -0,0 +1,87 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.AggregateOperator
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.types.*
import math.Ordered.orderingToOrdered
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
import scala.annotation.unused
object Maximum extends CompNodeFactory:
override val Key: String = "Maximum"
def apply(node: CompNode): CompNode =
node match
case node: IntNode =>
IntNode(
Expression.Aggregate(
node.expr,
summon[MaximumOperator[Int]],
),
)
case node: DollarNode =>
DollarNode(
Expression.Aggregate(
node.expr,
summon[MaximumOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Aggregate(
node.expr,
summon[MaximumOperator[Rational]],
),
)
case node: DayNode =>
DayNode(
Expression.Aggregate(
node.expr,
summon[MaximumOperator[Day]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot execute minimum on a ${node.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(CompNode.getConfigChildNode(e))
private final class MaximumOperator[A: Ordering] extends AggregateOperator[A, A]:
override def apply(vect: MaybeVector[Thunk[Result[A]]]): Result[A] =
vect.toList match
case Nil =>
Result.Incomplete
case thunk :: Nil =>
thunk.get
case thunk :: thunks =>
accumulator(thunks, thunk.get)
@scala.annotation.tailrec
private def accumulator(
thunks: List[Thunk[Result[A]]],
a: Result[A],
): Result[A] = thunks match
case thunk :: thunks =>
thunk.get match
case Result(value, complete) =>
val max = if Ordering[A].gt(value, a.get) then value else a.get
accumulator(
thunks,
Result(max, complete && a.complete),
)
case _ => Result.Incomplete
case Nil => a
@unused
private object MaximumOperator:
implicit val intOperator: MaximumOperator[Int] = MaximumOperator[Int]
implicit val dollarOperator: MaximumOperator[Dollar] = MaximumOperator[Dollar]
implicit val rationalOperator: MaximumOperator[Rational] =
MaximumOperator[Rational]
implicit val dayOperator: MaximumOperator[Day] = MaximumOperator[Day]

View file

@ -0,0 +1,92 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait}
import gov.irs.factgraph.operators.AggregateOperator
import gov.irs.factgraph.monads.*
import gov.irs.factgraph.types.*
import math.Ordered.orderingToOrdered
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path, PathItem}
import scala.annotation.unused
object Minimum extends CompNodeFactory:
override val Key: String = "Minimum"
def apply(node: CompNode): CompNode =
node match
case node: IntNode =>
IntNode(
Expression.Aggregate(
node.expr,
summon[MinimumOperator[Int]],
),
)
case node: DollarNode =>
DollarNode(
Expression.Aggregate(
node.expr,
summon[MinimumOperator[Dollar]],
),
)
case node: RationalNode =>
RationalNode(
Expression.Aggregate(
node.expr,
summon[MinimumOperator[Rational]],
),
)
case node: DayNode =>
DayNode(
Expression.Aggregate(
node.expr,
summon[MinimumOperator[Day]],
),
)
case _ =>
throw new UnsupportedOperationException(
s"cannot execute minimum on a ${node.getClass.getName}",
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
this(CompNode.getConfigChildNode(e))
private final class MinimumOperator[A: Ordering] extends AggregateOperator[A, A]:
override def apply(vect: MaybeVector[Thunk[Result[A]]]): Result[A] =
vect.toList match
case Nil =>
Result.Incomplete
case thunk :: Nil =>
thunk.get
case thunk :: thunks =>
accumulator(thunks, thunk.get)
@scala.annotation.tailrec
private def accumulator(
thunks: List[Thunk[Result[A]]],
a: Result[A],
): Result[A] = thunks match
case thunk :: thunks =>
thunk.get match
case Result(value, complete) =>
val min = if Ordering[A].lt(value, a.get) then value else a.get
accumulator(
thunks,
Result(min, complete && a.complete),
)
case _ => Result.Incomplete
case Nil => a
//3/23/2023
// you might be thinking to yourself...
// "it sure is strange that he has this unused code here"
// Well... this gives a hint to the compiler and allows the
// accumulator operators to build.
@unused
private object MinimumOperator:
implicit val intOperator: MinimumOperator[Int] = MinimumOperator[Int]
implicit val dollarOperator: MinimumOperator[Dollar] = MinimumOperator[Dollar]
implicit val rationalOperator: MinimumOperator[Rational] =
MinimumOperator[Rational]
implicit val dayOperator: MinimumOperator[Day] = MinimumOperator[Day]

View file

@ -0,0 +1,63 @@
package gov.irs.factgraph.compnodes
import gov.irs.factgraph.{Expression, FactDictionary, Factual, Path}
import gov.irs.factgraph.definitions.fact.{CommonOptionConfigTraits, CompNodeConfigTrait, WritableConfigTrait}
final case class MultiEnumNode(
expr: Expression[gov.irs.factgraph.types.MultiEnum],
enumOptionsPath: Path,
) extends CompNode:
type Value = gov.irs.factgraph.types.MultiEnum
override def ValueClass = classOf[gov.irs.factgraph.types.MultiEnum];
override private[compnodes] def fromExpression(
expr: Expression[gov.irs.factgraph.types.MultiEnum],
): CompNode =
MultiEnumNode(expr, enumOptionsPath)
object MultiEnumNode extends CompNodeFactory with WritableNodeFactory:
override val Key: String = "MultiEnum"
override def fromWritableConfig(e: WritableConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val enumOptionsPath = e.options.find(x => x.name == "optionsPath")
if (enumOptionsPath == None) {
throw new IllegalArgumentException(
s"MultiEnum must contain ${CommonOptionConfigTraits.ENUM_OPTIONS_PATH}",
)
}
new MultiEnumNode(
Expression.Writable(classOf[gov.irs.factgraph.types.MultiEnum]),
Path(enumOptionsPath.get.value),
)
def apply(value: gov.irs.factgraph.types.MultiEnum): MultiEnumNode =
new MultiEnumNode(
Expression.Constant(Some(value)),
Path(value.enumOptionsPath),
)
override def fromDerivedConfig(e: CompNodeConfigTrait)(using Factual)(using
FactDictionary,
): CompNode =
val dictionary = summon[FactDictionary]
val enumOptionsPath =
e.getOptionValue(CommonOptionConfigTraits.ENUM_OPTIONS_PATH)
val values = e
.getOptionValue(CommonOptionConfigTraits.VALUE)
.get
.split(",")
.map(_.trim)
.toSet
if (enumOptionsPath == None) {
throw new IllegalArgumentException(
s"MultiEnum must contain ${CommonOptionConfigTraits.ENUM_OPTIONS_PATH}",
)
}
this(
gov.irs.factgraph.types.MultiEnum
.apply(values, enumOptionsPath.get),
)

Some files were not shown because too many files have changed in this diff Show more