├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── Sources
└── Rules
│ ├── Rule.swift
│ ├── RuleEditor.swift
│ ├── RuleResult.swift
│ └── RuleSystem.swift
├── Tests
└── RulesTests
│ ├── RuleEditorTests.swift
│ ├── RuleResultTests.swift
│ └── RulesTests.swift
└── readme.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Boost Software License - Version 1.0 - August 17th, 2003
2 |
3 | Permission is hereby granted, free of charge, to any person or organization
4 | obtaining a copy of the software and accompanying documentation covered by
5 | this license (the "Software") to use, reproduce, display, distribute,
6 | execute, and transmit the Software, and to prepare derivative works of the
7 | Software, and to permit third-parties to whom the Software is furnished to
8 | do so, all subject to the following:
9 |
10 | The copyright notices in the Software and this entire statement, including
11 | the above license grant, this restriction and the following disclaimer,
12 | must be included in all copies of the Software, in whole or in part, and
13 | all derivative works of the Software, unless such copies or derivative
14 | works are solely in the form of machine-executable object code generated by
15 | a source language processor.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
20 | SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
21 | FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
22 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
23 | DEALINGS IN THE SOFTWARE.
24 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Rules",
8 | products: [
9 | // Products define the executables and libraries a package produces, making them visible to other packages.
10 | .library(
11 | name: "Rules",
12 | targets: ["Rules"]),
13 | ],
14 | targets: [
15 | // Targets are the basic building blocks of a package, defining a module or a test suite.
16 | // Targets can depend on other targets in this package and products from dependencies.
17 | .target(
18 | name: "Rules"),
19 | .testTarget(
20 | name: "RulesTests",
21 | dependencies: ["Rules"]),
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/Sources/Rules/Rule.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Rule
3 | // Copyright © 2024 Dan Griffin. All rights reserved.
4 | //
5 |
6 | import Foundation
7 |
8 | /// A rule to be used in the context of a rule system, with a predicate to be tested
9 | /// and an action to be executed when the test succeeds.
10 | open class Rule {
11 |
12 | /// The importance of the rule relative to others in a rule system’s agenda.
13 | var salience: Int = 0
14 |
15 | private let predicate: (RuleEditor) -> Bool
16 | private let action: (RuleEditor) -> Void
17 |
18 | public init(salience: Int = 0,
19 | predicate: @escaping (RuleEditor) -> Bool,
20 | action: @escaping (RuleEditor) -> Void) {
21 | self.salience = salience
22 | self.predicate = predicate
23 | self.action = action
24 | }
25 |
26 | open func evaluatePredicate(editor: RuleEditor) -> Bool {
27 | return predicate(editor)
28 | }
29 |
30 | open func performAction(editor: RuleEditor) {
31 | action(editor)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Sources/Rules/RuleEditor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RuleEditor
3 | // Copyright © 2024 Dan Griffin. All rights reserved.
4 | //
5 |
6 | import Foundation
7 |
8 | public class RuleEditor {
9 | public private(set) var state: State
10 |
11 | public private(set) var assertedFactsAndGrades = [Fact: Double]()
12 |
13 | internal init(state: State) {
14 | self.state = state
15 | }
16 |
17 | public func makeResult(executedRules: [Rule] = []) -> RuleResult {
18 | return RuleResult(state: self.state,
19 | assertedFactsAndGrades: self.assertedFactsAndGrades,
20 | executedRules: executedRules)
21 | }
22 |
23 | private static func clamp(_ grade: Double) -> Double {
24 | return max(0, min(1.0, grade))
25 | }
26 |
27 | public func assert(_ fact: Fact, grade: Double = 1.0) {
28 | if let currentFactGrade = assertedFactsAndGrades[fact] {
29 | let updatedFactGrade = Self.clamp(currentFactGrade + grade)
30 | self.assertedFactsAndGrades[fact] = updatedFactGrade
31 | } else {
32 | self.assertedFactsAndGrades[fact] = Self.clamp(grade)
33 | }
34 | }
35 |
36 | public func retract(_ fact: Fact, grade: Double = 1.0) {
37 | // We only can retract facts that are currently established
38 | guard let currentFactGrade = assertedFactsAndGrades[fact] else {
39 | return
40 | }
41 |
42 | let updatedFactGrade = currentFactGrade - grade
43 | if updatedFactGrade > 0.0 {
44 | assertedFactsAndGrades[fact] = updatedFactGrade
45 | } else {
46 | assertedFactsAndGrades.removeValue(forKey: fact)
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/Rules/RuleResult.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RuleResult
3 | // Copyright © 2024 Dan Griffin. All rights reserved.
4 | //
5 |
6 | import Foundation
7 |
8 | public struct RuleResult {
9 |
10 | /// An immutable copy of the state associated with the evaluated rule result
11 | public let state: State
12 |
13 | /// A raw copy of the facts and grades generated from the evaluation
14 | private let assertedFactsAndGrades: [Fact: Double]
15 |
16 | /// The list of facts claimed by the rule system
17 | public var facts: [Fact] {
18 | return assertedFactsAndGrades.keys.map { $0 }
19 | }
20 |
21 | public let executedRules: [Rule]
22 |
23 | internal init(state: State,
24 | assertedFactsAndGrades: [Fact : Double],
25 | executedRules: [Rule]) {
26 |
27 | self.state = state
28 | self.assertedFactsAndGrades = assertedFactsAndGrades
29 | self.executedRules = executedRules
30 | }
31 |
32 | /// Returns the membership grade of the specified fact.
33 | ///
34 | /// If the specified fact is not in the facts array, this method returns 0.0.
35 | public func grade(for fact: Fact) -> Double {
36 | return self.assertedFactsAndGrades[fact] ?? 0.0
37 | }
38 |
39 | /// Returns the lowest membership grade among the specified facts.
40 | ///
41 | /// In fuzzy logic, this method is called the AND Zadeh operator, because it
42 | /// corresponds to the AND operator in Boolean logic.
43 | ///
44 | /// If a fact is not in the facts array, its membership grade for purposes of this
45 | /// operation is implicitly zero.
46 | public func minimumGrade(for facts: [Fact]) -> Double {
47 | return facts.map {
48 | return self.assertedFactsAndGrades[$0] ?? 0.0
49 | }.min() ?? 0.0
50 | }
51 |
52 | /// Returns the highest membership grade among the specified facts.
53 | ///
54 | /// In fuzzy logic, this method is called the OR Zadeh operator, because it
55 | /// corresponds to the OR operator in Boolean logic.
56 | ///
57 | /// If a fact is not in the facts array, its membership grade for purposes of this
58 | /// operation is implicitly zero.
59 | public func maximumGrade(for facts: [Fact]) -> Double {
60 | return facts.map {
61 | return self.assertedFactsAndGrades[$0] ?? 0.0
62 | }.max() ?? 0.0
63 | }
64 | }
65 |
66 |
--------------------------------------------------------------------------------
/Sources/Rules/RuleSystem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RuleSystem
3 | // Copyright © 2024 Dan Griffin. All rights reserved.
4 | //
5 |
6 | import Foundation
7 |
8 | /// A RuleSystem object manages a list of rules
9 | public class RuleSystem {
10 |
11 | public init() { }
12 |
13 | // MARK: Managing Rules
14 |
15 | public private(set) var rules = [Rule]()
16 |
17 | public func add(rule: Rule) {
18 | self.rules.append(rule)
19 | }
20 |
21 | public func add(rules rulesToAdd: [Rule]) {
22 | for rule in rulesToAdd {
23 | self.rules.append(rule)
24 | }
25 | }
26 |
27 | public func removeAllRules() {
28 | self.rules = []
29 | }
30 |
31 | private func makeAgenda() -> [Rule] {
32 | return self.rules.sorted { $0.salience < $1.salience }
33 | }
34 |
35 | // MARK: Evaluate
36 |
37 | public func evaluate(state: State) -> RuleResult {
38 | let ruleEditor = RuleEditor(state: state)
39 |
40 | var agenda = self.makeAgenda()
41 | var executedRules = [Rule]()
42 |
43 | var loopInterval = 0
44 |
45 | while true {
46 | guard agenda.indices.contains(loopInterval) else {
47 | break
48 | }
49 |
50 | let rule = agenda[loopInterval]
51 |
52 | // Either we evaluate (and remove the item at the current index)
53 | // or we bump the index forward
54 | if rule.evaluatePredicate(editor: ruleEditor) {
55 | agenda.remove(at: loopInterval)
56 | executedRules.append(rule)
57 |
58 | rule.performAction(editor: ruleEditor)
59 |
60 | // Reset Agenda loop level since we just evaluated a rule.
61 | loopInterval = 0
62 | } else {
63 | loopInterval += 1
64 | }
65 | }
66 |
67 | return ruleEditor.makeResult(executedRules: executedRules)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Tests/RulesTests/RuleEditorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RuleEditorTests
3 | // Copyright © 2024 Dan Griffin. All rights reserved.
4 | //
5 |
6 | import XCTest
7 | @testable import Rules
8 |
9 | final class RuleEditorTests: XCTestCase {
10 |
11 | func testAssertionExample() throws {
12 | struct State { }
13 | enum Fact {
14 | case playerInSight
15 | }
16 |
17 | let state = State()
18 | let ruleEditor = RuleEditor(state: state)
19 |
20 | ruleEditor.assert(.playerInSight, grade: 1.0)
21 | XCTAssertEqual(ruleEditor.assertedFactsAndGrades[.playerInSight], 1.0)
22 | }
23 |
24 | func testAssertionOverflowExample() throws {
25 | struct State { }
26 | enum Fact {
27 | case playerInSight
28 | }
29 |
30 | let state = State()
31 | let ruleEditor = RuleEditor(state: state)
32 |
33 | ruleEditor.assert(.playerInSight, grade: 2.0)
34 | XCTAssertEqual(ruleEditor.assertedFactsAndGrades[.playerInSight], 1.0)
35 | }
36 |
37 | func testAssertionAdditionOnlyExample() throws {
38 | struct State { }
39 | enum Fact {
40 | case playerInSight
41 | }
42 |
43 | let state = State()
44 | let ruleEditor = RuleEditor(state: state)
45 |
46 | ruleEditor.assert(.playerInSight, grade: 0.3)
47 | ruleEditor.assert(.playerInSight, grade: 0.3)
48 | XCTAssertEqual(ruleEditor.assertedFactsAndGrades[.playerInSight], 0.6)
49 | }
50 |
51 | func testAssertionAdditionOverflowExample() throws {
52 | struct State { }
53 | enum Fact {
54 | case playerInSight
55 | }
56 |
57 | let state = State()
58 | let ruleEditor = RuleEditor(state: state)
59 |
60 | ruleEditor.assert(.playerInSight, grade: 0.5)
61 | ruleEditor.assert(.playerInSight, grade: 0.7)
62 | XCTAssertEqual(ruleEditor.assertedFactsAndGrades[.playerInSight], 1.0)
63 | }
64 |
65 | func testRetractionExample() throws {
66 | struct State { }
67 | enum Fact {
68 | case playerInSight
69 | }
70 |
71 | let state = State()
72 | let ruleEditor = RuleEditor(state: state)
73 |
74 | ruleEditor.assert(.playerInSight, grade: 1.0)
75 | ruleEditor.retract(.playerInSight, grade: 0.5)
76 |
77 | XCTAssertEqual(ruleEditor.assertedFactsAndGrades[.playerInSight], 0.5)
78 | }
79 |
80 | func testRetractionRemovalExample() throws {
81 | struct State { }
82 | enum Fact {
83 | case playerInSight
84 | }
85 |
86 | let state = State()
87 | let ruleEditor = RuleEditor(state: state)
88 |
89 | ruleEditor.assert(.playerInSight, grade: 1.0)
90 | ruleEditor.retract(.playerInSight, grade: 1.0)
91 |
92 | XCTAssertNil(ruleEditor.assertedFactsAndGrades[.playerInSight])
93 | }
94 |
95 | func testRetractionOverflowRemovalExample() throws {
96 | struct State { }
97 | enum Fact {
98 | case playerInSight
99 | }
100 |
101 | let state = State()
102 | let ruleEditor = RuleEditor(state: state)
103 |
104 | ruleEditor.assert(.playerInSight, grade: 1.0)
105 | ruleEditor.retract(.playerInSight, grade: 1.0)
106 | ruleEditor.retract(.playerInSight, grade: 1.0)
107 |
108 | XCTAssertNil(ruleEditor.assertedFactsAndGrades[.playerInSight])
109 | }
110 |
111 | func testRetractionOverflowRemovalAlternateExample() throws {
112 | struct State { }
113 | enum Fact {
114 | case playerInSight
115 | }
116 |
117 | let state = State()
118 | let ruleEditor = RuleEditor(state: state)
119 |
120 | ruleEditor.assert(.playerInSight, grade: 1.0)
121 | ruleEditor.retract(.playerInSight, grade: 0.5)
122 | ruleEditor.retract(.playerInSight, grade: 1.0)
123 |
124 | XCTAssertNil(ruleEditor.assertedFactsAndGrades[.playerInSight])
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/Tests/RulesTests/RuleResultTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RuleResultTests
3 | // Copyright © 2024 Dan Griffin. All rights reserved.
4 | //
5 |
6 | import XCTest
7 | @testable import Rules
8 |
9 | final class RuleResultTests: XCTestCase {
10 |
11 | func testGrade() throws {
12 | struct State { }
13 | enum Fact {
14 | case playerInSight
15 | case chargeLevel
16 | }
17 |
18 | let result = RuleResult(state: State(),
19 | assertedFactsAndGrades: [
20 | .playerInSight: 1.0,
21 | .chargeLevel: 0.5
22 | ],
23 | executedRules: [])
24 |
25 | XCTAssertEqual(result.grade(for: .playerInSight), 1.0)
26 | XCTAssertEqual(result.grade(for: .chargeLevel), 0.5)
27 | }
28 |
29 | func testGradeMinimum() throws {
30 | struct State { }
31 | enum Fact {
32 | case playerInSight
33 | case chargeLevel
34 | case fleeAbility
35 | }
36 |
37 | let result = RuleResult(state: State(),
38 | assertedFactsAndGrades: [
39 | .playerInSight: 1.0,
40 | .chargeLevel: 0.5
41 | ],
42 | executedRules: [])
43 |
44 | XCTAssertEqual(result.minimumGrade(for: [
45 | .playerInSight, .chargeLevel
46 | ]), 0.5)
47 | }
48 |
49 | func testGradeImpliedZero() throws {
50 | struct State { }
51 | enum Fact {
52 | case playerInSight
53 | case chargeLevel
54 | case fleeAbility
55 | }
56 |
57 | let result = RuleResult(state: State(),
58 | assertedFactsAndGrades: [
59 | .playerInSight: 1.0,
60 | .chargeLevel: 0.5
61 | ],
62 | executedRules: [])
63 |
64 | XCTAssertEqual(result.minimumGrade(for: [
65 | .playerInSight, .chargeLevel, .fleeAbility
66 | ]), 0.0)
67 | }
68 |
69 | func testGradeMaximum() throws {
70 | struct State { }
71 | enum Fact {
72 | case playerInSight
73 | case chargeLevel
74 | case fleeAbility
75 | }
76 |
77 | let result = RuleResult(state: State(),
78 | assertedFactsAndGrades: [
79 | .playerInSight: 1.0,
80 | .chargeLevel: 0.5
81 | ],
82 | executedRules: [])
83 |
84 | XCTAssertEqual(result.maximumGrade(for: [
85 | .playerInSight,
86 | .chargeLevel,
87 | .fleeAbility
88 | ]), 1.0)
89 | }
90 |
91 | func testGradeMaximumImpliedZero() throws {
92 | struct State { }
93 | enum Fact {
94 | case playerInSight
95 | case chargeLevel
96 | case fleeAbility
97 | }
98 |
99 | let result = RuleResult(state: State(),
100 | assertedFactsAndGrades: [
101 | .playerInSight: 1.0,
102 | .chargeLevel: 0.5
103 | ],
104 | executedRules: [])
105 |
106 | XCTAssertEqual(result.maximumGrade(for: [
107 | .fleeAbility
108 | ]), 0.0)
109 | }
110 |
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/Tests/RulesTests/RulesTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Rules
3 |
4 | final class RulesTests: XCTestCase {
5 | func testSimpleBooleanExample() throws {
6 | struct State {
7 | var distance: Int
8 | }
9 |
10 | enum Fact {
11 | case playerInSight
12 | }
13 |
14 | let ruleSystem = RuleSystem()
15 |
16 | let closeToPlayerRule = Rule { editor in
17 | return editor.state.distance <= 50
18 | } action: { editor in
19 | editor.assert(.playerInSight, grade: 1.0)
20 | }
21 |
22 | let farFromPlayerRule = Rule { editor in
23 | return editor.state.distance > 50
24 | } action: { editor in
25 | editor.retract(.playerInSight, grade: 1.0)
26 | }
27 |
28 | ruleSystem.add(rules: [closeToPlayerRule, farFromPlayerRule])
29 |
30 | let result = ruleSystem.evaluate(state: State(distance: 20))
31 | let grade = result.grade(for: .playerInSight)
32 | XCTAssertEqual(grade, 1.0)
33 |
34 | let result2 = ruleSystem.evaluate(state: State(distance: 75))
35 | let grade2 = result2.grade(for: .playerInSight)
36 | XCTAssertEqual(grade2, 0)
37 | }
38 |
39 | func testSimpleFuzzyExample() throws {
40 | struct State {
41 | var distance: Double
42 | var maxSightDistance: Double
43 | }
44 |
45 | enum Fact {
46 | case playerNear
47 | }
48 |
49 | let ruleSystem = RuleSystem()
50 |
51 | let closeToPlayerRule = Rule { editor in
52 | return editor.state.distance <= 30
53 | } action: { editor in
54 | // The closer, the higher the value
55 | let isNearValue = Double(1.0 - editor.state.distance / editor.state.maxSightDistance)
56 | editor.assert(.playerNear, grade: isNearValue)
57 | }
58 |
59 | ruleSystem.add(rule: closeToPlayerRule)
60 |
61 | let state = State(distance: 15, maxSightDistance: 30)
62 | let result = ruleSystem.evaluate(state: state)
63 | let grade = result.grade(for: .playerNear)
64 | XCTAssertEqual(grade, 0.5)
65 |
66 | let state2 = State(distance: 35, maxSightDistance: 30)
67 | let result2 = ruleSystem.evaluate(state: state2)
68 | let grade2 = result2.grade(for: .playerNear)
69 | XCTAssertEqual(grade2, 0)
70 | }
71 |
72 | func testRunsInAddedSequenceOrderExample() throws {
73 | struct State { }
74 |
75 | enum Fact {
76 | case didRun
77 | }
78 |
79 |
80 | // Runs before the other, even though its second in the array
81 | let firstRule = Rule { editor in
82 | return true
83 | } action: { editor in
84 | editor.retract(.didRun, grade: 1.0)
85 | }
86 |
87 | // Runs second, despite being first in the array.
88 | let secondRule = Rule { editor in
89 | return true
90 | } action: { editor in
91 | editor.assert(.didRun, grade: 1.0)
92 | }
93 |
94 | let ruleSystem = RuleSystem()
95 | ruleSystem.add(rules: [secondRule, firstRule])
96 | let result = ruleSystem.evaluate(state: State())
97 | let grade = result.grade(for: .didRun)
98 | XCTAssertEqual(grade, 0.0)
99 |
100 | let ruleSystem2 = RuleSystem()
101 | ruleSystem2.add(rules: [firstRule, secondRule])
102 | let result2 = ruleSystem2.evaluate(state: State())
103 | let grade2 = result2.grade(for: .didRun)
104 | XCTAssertEqual(grade2, 1.0)
105 | }
106 |
107 | func testSalienceOrderExample() throws {
108 | struct State { }
109 |
110 | enum Fact {
111 | case didRun
112 | }
113 |
114 | let ruleSystem = RuleSystem()
115 |
116 | // Runs before the other, even though its second in the array
117 | let firstRule = Rule(salience: 0) { editor in
118 | return true
119 | } action: { editor in
120 | editor.retract(.didRun, grade: 1.0)
121 | }
122 |
123 | // Runs second, despite being first in the array.
124 | let secondRule = Rule(salience: 1) { editor in
125 | return true
126 | } action: { editor in
127 | editor.assert(.didRun, grade: 1.0)
128 | }
129 |
130 | ruleSystem.add(rules: [secondRule, firstRule])
131 |
132 | let result2 = ruleSystem.evaluate(state: State())
133 | let grade2 = result2.grade(for: .didRun)
134 | XCTAssertEqual(grade2, 1.0)
135 | }
136 |
137 | func testRunsInAddedSequenceButAlsoSalienceOrderExample() throws {
138 | struct State { }
139 | enum Fact {
140 | case didRun
141 | }
142 |
143 | // Used to track the order the rules run in for the test
144 | var runOrder = 0
145 |
146 | // Runs First
147 | let firstRule = Rule { editor in
148 | return true
149 | } action: { editor in
150 | XCTAssertEqual(runOrder, 0)
151 | runOrder += 1
152 | }
153 |
154 | // Runs Third even tho listed second because the others inherit salience 0 and run
155 | // in order before.
156 | let secondRule = Rule(salience: 1) { editor in
157 | return true
158 | } action: { editor in
159 | XCTAssertEqual(runOrder, 2)
160 | runOrder += 1
161 | }
162 |
163 | // Runs Second
164 | let thirdRule = Rule { editor in
165 | return true
166 | } action: { editor in
167 | XCTAssertEqual(runOrder, 1)
168 | runOrder += 1
169 | }
170 |
171 | let ruleSystem = RuleSystem()
172 | ruleSystem.add(rules: [firstRule, secondRule, thirdRule])
173 | let _ = ruleSystem.evaluate(state: State())
174 |
175 | // Make sure we've run :)
176 | XCTAssertEqual(runOrder, 3)
177 | }
178 |
179 | func testReevaluatesRulesetExample() throws {
180 | struct State { }
181 |
182 | enum Fact {
183 | case didRun
184 | }
185 |
186 | let ruleSystem = RuleSystem()
187 |
188 | // Skipped until the second rule has been run
189 | let firstRule = Rule(salience: 0) { editor in
190 | // Only run if we have an asserted fact.
191 | // We then retract that fact
192 | return editor.assertedFactsAndGrades.keys.contains(.didRun)
193 | } action: { editor in
194 | editor.retract(.didRun, grade: 1.0)
195 | }
196 |
197 | // After this runs, the evaluation is clear to run the first rule.
198 | let secondRule = Rule(salience: 1) { editor in
199 | return true
200 | } action: { editor in
201 | editor.assert(.didRun, grade: 1.0)
202 | }
203 |
204 | ruleSystem.add(rules: [firstRule, secondRule])
205 |
206 | let result2 = ruleSystem.evaluate(state: State())
207 | let grade2 = result2.grade(for: .didRun)
208 | XCTAssertEqual(grade2, 0)
209 | }
210 |
211 |
212 | func testSecondHandExample() throws {
213 | struct State {
214 | var distance: Double
215 | var maxSightDistance: Double
216 |
217 | var charge: Double
218 | var fullCharge: Double
219 | }
220 |
221 | enum Fact {
222 | case playerNear
223 | case laserChargePower
224 | case shouldFire
225 | }
226 |
227 | let ruleSystem = RuleSystem()
228 |
229 | let closeToPlayerRule = Rule { editor in
230 | return editor.state.distance <= 50
231 | } action: { editor in
232 | // The closer, the higher the value
233 | let isNearValue = Double(1.0 - editor.state.distance / editor.state.maxSightDistance)
234 | editor.assert(.playerNear, grade: isNearValue)
235 | }
236 |
237 | let chargeLevel = Rule { editor in
238 | return true
239 | } action: { editor in
240 | // The more full, the higher
241 | let isAlmostCharged = editor.state.charge / editor.state.fullCharge
242 | editor.assert(.laserChargePower, grade: isAlmostCharged)
243 | }
244 |
245 | let shouldFireRule = Rule(salience: 1) { editor in
246 | return true
247 | } action: { editor in
248 | let partialResult = editor.makeResult()
249 | let shouldFire = partialResult.minimumGrade(for: [
250 | .playerNear,
251 | .laserChargePower
252 | ])
253 | editor.assert(.shouldFire, grade: shouldFire)
254 | }
255 |
256 | ruleSystem.add(rules: [closeToPlayerRule, chargeLevel, shouldFireRule])
257 |
258 | let state = State(
259 | distance: 15,
260 | maxSightDistance: 30,
261 | charge: 60,
262 | fullCharge: 100
263 | )
264 | let result = ruleSystem.evaluate(state: state)
265 | let grade = result.grade(for: .shouldFire)
266 |
267 | XCTAssertEqual(grade, 0.5)
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Rules
2 |
3 | A simple fuzzy logic engine that mirrors GKRule and GKRuleSystem, but written in Swift, typed, and produces a less ambiguous state by introducing additional lightweight components.
4 |
5 | The full list of components are:
6 | - `Rule`: Objects the user defines for interpreting state and making assertions about facts.
7 | - `RuleSystem`: An object used for evaluating a set of rules.
8 | - `RuleEditor`: An object provided to rules for accessing state, making their assertions, or getting information from already asserted facts.
9 | - `RuleResult`: The final structure which allows you to query the fuzzy result for included facts and grades.
10 |
11 | This replicates the majority of the rule/rule system introduced in GameplayKit, but omits the plist and serialization archiving because that's something I don't plan on using.
12 |
13 | ## Simple Example
14 |
15 | ```swift
16 | // 1
17 | struct State {
18 | var distance: Int
19 | }
20 |
21 | enum Fact {
22 | case playerInSight
23 | }
24 |
25 | // 2
26 | let closeToPlayerRule = Rule { editor in
27 | return editor.state.distance <= 50
28 | } action: { editor in
29 | editor.assert(.playerInSight, grade: 1.0)
30 | }
31 |
32 | let farFromPlayerRule = Rule { editor in
33 | return editor.state.distance > 50
34 | } action: { editor in
35 | editor.retract(.playerInSight, grade: 1.0)
36 | }
37 |
38 | // 3
39 | let ruleSystem = RuleSystem()
40 | ruleSystem.add(rules: [closeToPlayerRule, farFromPlayerRule])
41 |
42 | // 4
43 | let state = State(distance: 20)
44 | let result = ruleSystem.evaluate(state: state)
45 |
46 | // 5
47 | let grade = result.grade(for: .playerInSight)
48 | ```
49 |
50 | 1. Define your State and Facts.
51 | - State is what your rules use to determine Facts
52 | - Facts are the output of the evaluation of the rules
53 | 2. Define your rules using Rule.
54 | 3. Create your rule system and add your rules. Only rules that understand the system's State and Facts are accepted into a system.
55 | 4. Generate a state and provide it to the evaluation function to get a `RuleResult`
56 | 5. Interpret results by using `grade`/`minimumGrade`/`maximumGrade` on the results.
57 |
58 | ## Real-World Example
59 | A more complicated system might work to blend values for more unique and emergent results:
60 |
61 | ```swift
62 | struct State {
63 | var distance: Double
64 | var maxSightDistance: Double
65 |
66 | var charge: Double
67 | var fullCharge: Double
68 | }
69 |
70 | enum Fact {
71 | case playerNear
72 | case laserChargePower
73 | case shouldFire
74 | }
75 |
76 | let ruleSystem = RuleSystem()
77 |
78 | let closeToPlayerRule = Rule { editor in
79 | return editor.state.distance <= 50
80 | } action: { editor in
81 | // The closer, the higher the value
82 | let isNearValue = Double(1.0 - editor.state.distance / editor.state.maxSightDistance)
83 | editor.assert(.playerNear, grade: isNearValue)
84 | }
85 |
86 | let chargeLevel = Rule { editor in
87 | return true
88 | } action: { editor in
89 | // The more full, the higher
90 | let isAlmostCharged = editor.state.charge / editor.state.fullCharge
91 | editor.assert(.laserChargePower, grade: isAlmostCharged)
92 | }
93 |
94 | let shouldFireRule = Rule(salience: 1) { editor in
95 | return true
96 | } action: { editor in
97 | let partialResult = editor.makeResult()
98 | // Note that minimum Grade is a logical AND in our fuzzy world, so we are asking:
99 | // shouldFire = .playerNear AND .laserChargePower
100 | let shouldFire = partialResult.minimumGrade(for: [
101 | .playerNear,
102 | .laserChargePower
103 | ])
104 | editor.assert(.shouldFire, grade: shouldFire)
105 | }
106 |
107 | ruleSystem.add(rules: [closeToPlayerRule, chargeLevel, shouldFireRule])
108 |
109 | let state = State(
110 | distance: 15,
111 | maxSightDistance: 30,
112 | charge: 60,
113 | fullCharge: 100
114 | )
115 | let result = ruleSystem.evaluate(state: state)
116 | let grade = result.grade(for: .shouldFire)
117 | ```
118 |
--------------------------------------------------------------------------------