├── .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 | --------------------------------------------------------------------------------