edsl 0.1.39.dev2__py3-none-any.whl → 0.1.39.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. edsl/Base.py +332 -385
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +49 -57
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +867 -1079
  7. edsl/agents/AgentList.py +413 -551
  8. edsl/agents/Invigilator.py +233 -285
  9. edsl/agents/InvigilatorBase.py +270 -254
  10. edsl/agents/PromptConstructor.py +354 -252
  11. edsl/agents/__init__.py +3 -2
  12. edsl/agents/descriptors.py +99 -99
  13. edsl/agents/prompt_helpers.py +129 -129
  14. edsl/auto/AutoStudy.py +117 -117
  15. edsl/auto/StageBase.py +230 -230
  16. edsl/auto/StageGenerateSurvey.py +178 -178
  17. edsl/auto/StageLabelQuestions.py +125 -125
  18. edsl/auto/StagePersona.py +61 -61
  19. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  20. edsl/auto/StagePersonaDimensionValues.py +74 -74
  21. edsl/auto/StagePersonaDimensions.py +69 -69
  22. edsl/auto/StageQuestions.py +73 -73
  23. edsl/auto/SurveyCreatorPipeline.py +21 -21
  24. edsl/auto/utilities.py +224 -224
  25. edsl/base/Base.py +279 -279
  26. edsl/config.py +157 -177
  27. edsl/conversation/Conversation.py +290 -290
  28. edsl/conversation/car_buying.py +58 -59
  29. edsl/conversation/chips.py +95 -95
  30. edsl/conversation/mug_negotiation.py +81 -81
  31. edsl/conversation/next_speaker_utilities.py +93 -93
  32. edsl/coop/PriceFetcher.py +54 -54
  33. edsl/coop/__init__.py +2 -2
  34. edsl/coop/coop.py +1028 -1090
  35. edsl/coop/utils.py +131 -131
  36. edsl/data/Cache.py +555 -562
  37. edsl/data/CacheEntry.py +233 -230
  38. edsl/data/CacheHandler.py +149 -170
  39. edsl/data/RemoteCacheSync.py +78 -78
  40. edsl/data/SQLiteDict.py +292 -292
  41. edsl/data/__init__.py +4 -5
  42. edsl/data/orm.py +10 -10
  43. edsl/data_transfer_models.py +73 -74
  44. edsl/enums.py +175 -195
  45. edsl/exceptions/BaseException.py +21 -21
  46. edsl/exceptions/__init__.py +54 -54
  47. edsl/exceptions/agents.py +42 -54
  48. edsl/exceptions/cache.py +5 -5
  49. edsl/exceptions/configuration.py +16 -16
  50. edsl/exceptions/coop.py +10 -10
  51. edsl/exceptions/data.py +14 -14
  52. edsl/exceptions/general.py +34 -34
  53. edsl/exceptions/jobs.py +33 -33
  54. edsl/exceptions/language_models.py +63 -63
  55. edsl/exceptions/prompts.py +15 -15
  56. edsl/exceptions/questions.py +91 -109
  57. edsl/exceptions/results.py +29 -29
  58. edsl/exceptions/scenarios.py +22 -29
  59. edsl/exceptions/surveys.py +37 -37
  60. edsl/inference_services/AnthropicService.py +87 -84
  61. edsl/inference_services/AwsBedrock.py +120 -118
  62. edsl/inference_services/AzureAI.py +217 -215
  63. edsl/inference_services/DeepInfraService.py +18 -18
  64. edsl/inference_services/GoogleService.py +148 -139
  65. edsl/inference_services/GroqService.py +20 -20
  66. edsl/inference_services/InferenceServiceABC.py +147 -80
  67. edsl/inference_services/InferenceServicesCollection.py +97 -122
  68. edsl/inference_services/MistralAIService.py +123 -120
  69. edsl/inference_services/OllamaService.py +18 -18
  70. edsl/inference_services/OpenAIService.py +224 -221
  71. edsl/inference_services/PerplexityService.py +163 -160
  72. edsl/inference_services/TestService.py +89 -92
  73. edsl/inference_services/TogetherAIService.py +170 -170
  74. edsl/inference_services/models_available_cache.py +118 -118
  75. edsl/inference_services/rate_limits_cache.py +25 -25
  76. edsl/inference_services/registry.py +41 -41
  77. edsl/inference_services/write_available.py +10 -10
  78. edsl/jobs/Answers.py +56 -43
  79. edsl/jobs/Jobs.py +898 -757
  80. edsl/jobs/JobsChecks.py +147 -172
  81. edsl/jobs/JobsPrompts.py +268 -270
  82. edsl/jobs/JobsRemoteInferenceHandler.py +239 -287
  83. edsl/jobs/__init__.py +1 -1
  84. edsl/jobs/buckets/BucketCollection.py +63 -104
  85. edsl/jobs/buckets/ModelBuckets.py +65 -65
  86. edsl/jobs/buckets/TokenBucket.py +251 -283
  87. edsl/jobs/interviews/Interview.py +661 -358
  88. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  89. edsl/jobs/interviews/InterviewExceptionEntry.py +186 -186
  90. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  91. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  92. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  93. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  94. edsl/jobs/interviews/ReportErrors.py +66 -66
  95. edsl/jobs/interviews/interview_status_enum.py +9 -9
  96. edsl/jobs/runners/JobsRunnerAsyncio.py +466 -421
  97. edsl/jobs/runners/JobsRunnerStatus.py +330 -330
  98. edsl/jobs/tasks/QuestionTaskCreator.py +242 -244
  99. edsl/jobs/tasks/TaskCreators.py +64 -64
  100. edsl/jobs/tasks/TaskHistory.py +450 -449
  101. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  102. edsl/jobs/tasks/task_status_enum.py +163 -161
  103. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  104. edsl/jobs/tokens/TokenUsage.py +34 -34
  105. edsl/language_models/KeyLookup.py +30 -0
  106. edsl/language_models/LanguageModel.py +668 -571
  107. edsl/language_models/ModelList.py +155 -153
  108. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  109. edsl/language_models/__init__.py +3 -2
  110. edsl/language_models/fake_openai_call.py +15 -15
  111. edsl/language_models/fake_openai_service.py +61 -61
  112. edsl/language_models/registry.py +190 -180
  113. edsl/language_models/repair.py +156 -156
  114. edsl/language_models/unused/ReplicateBase.py +83 -0
  115. edsl/language_models/utilities.py +64 -65
  116. edsl/notebooks/Notebook.py +258 -263
  117. edsl/notebooks/__init__.py +1 -1
  118. edsl/prompts/Prompt.py +362 -352
  119. edsl/prompts/__init__.py +2 -2
  120. edsl/questions/AnswerValidatorMixin.py +289 -334
  121. edsl/questions/QuestionBase.py +664 -509
  122. edsl/questions/QuestionBaseGenMixin.py +161 -165
  123. edsl/questions/QuestionBasePromptsMixin.py +217 -221
  124. edsl/questions/QuestionBudget.py +227 -227
  125. edsl/questions/QuestionCheckBox.py +359 -359
  126. edsl/questions/QuestionExtract.py +182 -182
  127. edsl/questions/QuestionFreeText.py +114 -113
  128. edsl/questions/QuestionFunctional.py +166 -166
  129. edsl/questions/QuestionList.py +231 -229
  130. edsl/questions/QuestionMultipleChoice.py +286 -330
  131. edsl/questions/QuestionNumerical.py +153 -151
  132. edsl/questions/QuestionRank.py +324 -314
  133. edsl/questions/Quick.py +41 -41
  134. edsl/questions/RegisterQuestionsMeta.py +71 -71
  135. edsl/questions/ResponseValidatorABC.py +174 -200
  136. edsl/questions/SimpleAskMixin.py +73 -74
  137. edsl/questions/__init__.py +26 -27
  138. edsl/questions/compose_questions.py +98 -98
  139. edsl/questions/decorators.py +21 -21
  140. edsl/questions/derived/QuestionLikertFive.py +76 -76
  141. edsl/questions/derived/QuestionLinearScale.py +87 -90
  142. edsl/questions/derived/QuestionTopK.py +93 -93
  143. edsl/questions/derived/QuestionYesNo.py +82 -82
  144. edsl/questions/descriptors.py +413 -427
  145. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  146. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  147. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  148. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  149. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  150. edsl/questions/prompt_templates/question_list.jinja +17 -17
  151. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  152. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  153. edsl/questions/question_registry.py +177 -177
  154. edsl/questions/settings.py +12 -12
  155. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  156. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  157. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  158. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  159. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  160. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  161. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  162. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  163. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  164. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  165. edsl/questions/templates/list/question_presentation.jinja +5 -5
  166. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  167. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  168. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  169. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  170. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  171. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  172. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  173. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  174. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  175. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  176. edsl/results/CSSParameterizer.py +108 -108
  177. edsl/results/Dataset.py +424 -587
  178. edsl/results/DatasetExportMixin.py +731 -653
  179. edsl/results/DatasetTree.py +275 -295
  180. edsl/results/Result.py +465 -451
  181. edsl/results/Results.py +1165 -1172
  182. edsl/results/ResultsDBMixin.py +238 -0
  183. edsl/results/ResultsExportMixin.py +43 -45
  184. edsl/results/ResultsFetchMixin.py +33 -33
  185. edsl/results/ResultsGGMixin.py +121 -121
  186. edsl/results/ResultsToolsMixin.py +98 -98
  187. edsl/results/Selector.py +135 -145
  188. edsl/results/TableDisplay.py +198 -125
  189. edsl/results/__init__.py +2 -2
  190. edsl/results/table_display.css +77 -77
  191. edsl/results/tree_explore.py +115 -115
  192. edsl/scenarios/FileStore.py +632 -511
  193. edsl/scenarios/Scenario.py +601 -498
  194. edsl/scenarios/ScenarioHtmlMixin.py +64 -65
  195. edsl/scenarios/ScenarioJoin.py +127 -131
  196. edsl/scenarios/ScenarioList.py +1287 -1430
  197. edsl/scenarios/ScenarioListExportMixin.py +52 -45
  198. edsl/scenarios/ScenarioListPdfMixin.py +261 -239
  199. edsl/scenarios/__init__.py +4 -3
  200. edsl/shared.py +1 -1
  201. edsl/study/ObjectEntry.py +173 -173
  202. edsl/study/ProofOfWork.py +113 -113
  203. edsl/study/SnapShot.py +80 -80
  204. edsl/study/Study.py +528 -521
  205. edsl/study/__init__.py +4 -4
  206. edsl/surveys/DAG.py +148 -148
  207. edsl/surveys/Memory.py +31 -31
  208. edsl/surveys/MemoryPlan.py +244 -244
  209. edsl/surveys/Rule.py +326 -327
  210. edsl/surveys/RuleCollection.py +387 -385
  211. edsl/surveys/Survey.py +1801 -1229
  212. edsl/surveys/SurveyCSS.py +261 -273
  213. edsl/surveys/SurveyExportMixin.py +259 -259
  214. edsl/surveys/{SurveyFlowVisualization.py → SurveyFlowVisualizationMixin.py} +179 -181
  215. edsl/surveys/SurveyQualtricsImport.py +284 -284
  216. edsl/surveys/__init__.py +3 -5
  217. edsl/surveys/base.py +53 -53
  218. edsl/surveys/descriptors.py +56 -60
  219. edsl/surveys/instructions/ChangeInstruction.py +49 -48
  220. edsl/surveys/instructions/Instruction.py +65 -56
  221. edsl/surveys/instructions/InstructionCollection.py +77 -82
  222. edsl/templates/error_reporting/base.html +23 -23
  223. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  224. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  225. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  226. edsl/templates/error_reporting/interview_details.html +115 -115
  227. edsl/templates/error_reporting/interviews.html +19 -19
  228. edsl/templates/error_reporting/overview.html +4 -4
  229. edsl/templates/error_reporting/performance_plot.html +1 -1
  230. edsl/templates/error_reporting/report.css +73 -73
  231. edsl/templates/error_reporting/report.html +117 -117
  232. edsl/templates/error_reporting/report.js +25 -25
  233. edsl/tools/__init__.py +1 -1
  234. edsl/tools/clusters.py +192 -192
  235. edsl/tools/embeddings.py +27 -27
  236. edsl/tools/embeddings_plotting.py +118 -118
  237. edsl/tools/plotting.py +112 -112
  238. edsl/tools/summarize.py +18 -18
  239. edsl/utilities/SystemInfo.py +28 -28
  240. edsl/utilities/__init__.py +22 -22
  241. edsl/utilities/ast_utilities.py +25 -25
  242. edsl/utilities/data/Registry.py +6 -6
  243. edsl/utilities/data/__init__.py +1 -1
  244. edsl/utilities/data/scooter_results.json +1 -1
  245. edsl/utilities/decorators.py +77 -77
  246. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  247. edsl/utilities/interface.py +627 -627
  248. edsl/utilities/naming_utilities.py +263 -263
  249. edsl/utilities/repair_functions.py +28 -28
  250. edsl/utilities/restricted_python.py +70 -70
  251. edsl/utilities/utilities.py +424 -436
  252. {edsl-0.1.39.dev2.dist-info → edsl-0.1.39.dev3.dist-info}/LICENSE +21 -21
  253. {edsl-0.1.39.dev2.dist-info → edsl-0.1.39.dev3.dist-info}/METADATA +10 -12
  254. edsl-0.1.39.dev3.dist-info/RECORD +277 -0
  255. edsl/agents/QuestionInstructionPromptBuilder.py +0 -128
  256. edsl/agents/QuestionOptionProcessor.py +0 -172
  257. edsl/agents/QuestionTemplateReplacementsBuilder.py +0 -137
  258. edsl/coop/CoopFunctionsMixin.py +0 -15
  259. edsl/coop/ExpectedParrotKeyHandler.py +0 -125
  260. edsl/exceptions/inference_services.py +0 -5
  261. edsl/inference_services/AvailableModelCacheHandler.py +0 -184
  262. edsl/inference_services/AvailableModelFetcher.py +0 -209
  263. edsl/inference_services/ServiceAvailability.py +0 -135
  264. edsl/inference_services/data_structures.py +0 -62
  265. edsl/jobs/AnswerQuestionFunctionConstructor.py +0 -188
  266. edsl/jobs/FetchInvigilator.py +0 -40
  267. edsl/jobs/InterviewTaskManager.py +0 -98
  268. edsl/jobs/InterviewsConstructor.py +0 -48
  269. edsl/jobs/JobsComponentConstructor.py +0 -189
  270. edsl/jobs/JobsRemoteInferenceLogger.py +0 -239
  271. edsl/jobs/RequestTokenEstimator.py +0 -30
  272. edsl/jobs/buckets/TokenBucketAPI.py +0 -211
  273. edsl/jobs/buckets/TokenBucketClient.py +0 -191
  274. edsl/jobs/decorators.py +0 -35
  275. edsl/jobs/jobs_status_enums.py +0 -9
  276. edsl/jobs/loggers/HTMLTableJobLogger.py +0 -304
  277. edsl/language_models/ComputeCost.py +0 -63
  278. edsl/language_models/PriceManager.py +0 -127
  279. edsl/language_models/RawResponseHandler.py +0 -106
  280. edsl/language_models/ServiceDataSources.py +0 -0
  281. edsl/language_models/key_management/KeyLookup.py +0 -63
  282. edsl/language_models/key_management/KeyLookupBuilder.py +0 -273
  283. edsl/language_models/key_management/KeyLookupCollection.py +0 -38
  284. edsl/language_models/key_management/__init__.py +0 -0
  285. edsl/language_models/key_management/models.py +0 -131
  286. edsl/notebooks/NotebookToLaTeX.py +0 -142
  287. edsl/questions/ExceptionExplainer.py +0 -77
  288. edsl/questions/HTMLQuestion.py +0 -103
  289. edsl/questions/LoopProcessor.py +0 -149
  290. edsl/questions/QuestionMatrix.py +0 -265
  291. edsl/questions/ResponseValidatorFactory.py +0 -28
  292. edsl/questions/templates/matrix/__init__.py +0 -1
  293. edsl/questions/templates/matrix/answering_instructions.jinja +0 -5
  294. edsl/questions/templates/matrix/question_presentation.jinja +0 -20
  295. edsl/results/MarkdownToDocx.py +0 -122
  296. edsl/results/MarkdownToPDF.py +0 -111
  297. edsl/results/TextEditor.py +0 -50
  298. edsl/results/smart_objects.py +0 -96
  299. edsl/results/table_data_class.py +0 -12
  300. edsl/results/table_renderers.py +0 -118
  301. edsl/scenarios/ConstructDownloadLink.py +0 -109
  302. edsl/scenarios/DirectoryScanner.py +0 -96
  303. edsl/scenarios/DocumentChunker.py +0 -102
  304. edsl/scenarios/DocxScenario.py +0 -16
  305. edsl/scenarios/PdfExtractor.py +0 -40
  306. edsl/scenarios/ScenarioSelector.py +0 -156
  307. edsl/scenarios/file_methods.py +0 -85
  308. edsl/scenarios/handlers/__init__.py +0 -13
  309. edsl/scenarios/handlers/csv.py +0 -38
  310. edsl/scenarios/handlers/docx.py +0 -76
  311. edsl/scenarios/handlers/html.py +0 -37
  312. edsl/scenarios/handlers/json.py +0 -111
  313. edsl/scenarios/handlers/latex.py +0 -5
  314. edsl/scenarios/handlers/md.py +0 -51
  315. edsl/scenarios/handlers/pdf.py +0 -68
  316. edsl/scenarios/handlers/png.py +0 -39
  317. edsl/scenarios/handlers/pptx.py +0 -105
  318. edsl/scenarios/handlers/py.py +0 -294
  319. edsl/scenarios/handlers/sql.py +0 -313
  320. edsl/scenarios/handlers/sqlite.py +0 -149
  321. edsl/scenarios/handlers/txt.py +0 -33
  322. edsl/surveys/ConstructDAG.py +0 -92
  323. edsl/surveys/EditSurvey.py +0 -221
  324. edsl/surveys/InstructionHandler.py +0 -100
  325. edsl/surveys/MemoryManagement.py +0 -72
  326. edsl/surveys/RuleManager.py +0 -172
  327. edsl/surveys/Simulator.py +0 -75
  328. edsl/surveys/SurveyToApp.py +0 -141
  329. edsl/utilities/PrettyList.py +0 -56
  330. edsl/utilities/is_notebook.py +0 -18
  331. edsl/utilities/is_valid_variable_name.py +0 -11
  332. edsl/utilities/remove_edsl_version.py +0 -24
  333. edsl-0.1.39.dev2.dist-info/RECORD +0 -352
  334. {edsl-0.1.39.dev2.dist-info → edsl-0.1.39.dev3.dist-info}/WHEEL +0 -0
@@ -1,385 +1,387 @@
1
- """A collection of rules for a survey."""
2
-
3
- from typing import List, Union, Any, Optional
4
- from collections import defaultdict, UserList, namedtuple
5
-
6
- from edsl.exceptions.surveys import (
7
- SurveyRuleCannotEvaluateError,
8
- SurveyRuleCollectionHasNoRulesAtNodeError,
9
- )
10
-
11
- from edsl.surveys.Rule import Rule
12
- from edsl.surveys.base import EndOfSurvey
13
- from edsl.surveys.DAG import DAG
14
-
15
-
16
- NextQuestion = namedtuple(
17
- "NextQuestion", "next_q, num_rules_found, expressions_evaluating_to_true, priority"
18
- )
19
-
20
-
21
- class RuleCollection(UserList):
22
- """A collection of rules for a particular survey."""
23
-
24
- def __init__(self, num_questions: Optional[int] = None, rules: List[Rule] = None):
25
- """Initialize the RuleCollection object.
26
-
27
- :param num_questions: The number of questions in the survey.
28
- :param rules: A list of Rule objects.
29
- """
30
- super().__init__(rules or [])
31
- self.num_questions = num_questions
32
-
33
- def __repr__(self):
34
- """Return a string representation of the RuleCollection object.
35
-
36
- Example usage:
37
-
38
- .. code-block:: python
39
-
40
- rule_collection = RuleCollection.example()
41
- _ = eval(repr(rule_collection))
42
-
43
- """
44
- return f"RuleCollection(rules={self.data}, num_questions={self.num_questions})"
45
-
46
- def to_dataset(self):
47
- """Return a Dataset object representation of the RuleCollection object."""
48
- from edsl.results.Dataset import Dataset
49
-
50
- keys = ["current_q", "expression", "next_q", "priority", "before_rule"]
51
- rule_list = {}
52
- for rule in sorted(self, key=lambda r: r.current_q):
53
- for k in keys:
54
- rule_list.setdefault(k, []).append(getattr(rule, k))
55
-
56
- return Dataset([{k: v} for k, v in rule_list.items()])
57
-
58
- def _repr_html_(self):
59
- """Return an HTML representation of the RuleCollection object."""
60
- from edsl.results.Dataset import Dataset
61
-
62
- return self.to_dataset()._repr_html_()
63
-
64
- def to_dict(self, add_edsl_version=True):
65
- """Create a dictionary representation of the RuleCollection object."""
66
- return {
67
- "rules": [rule.to_dict() for rule in self],
68
- "num_questions": self.num_questions,
69
- }
70
-
71
- @classmethod
72
- def from_dict(cls, rule_collection_dict):
73
- """Create a RuleCollection object from a dictionary.
74
-
75
- >>> rule_collection = RuleCollection.example()
76
- >>> rule_collection_dict = rule_collection.to_dict()
77
- >>> new_rule_collection = RuleCollection.from_dict(rule_collection_dict)
78
- >>> repr(new_rule_collection) == repr(rule_collection)
79
- True
80
- """
81
- rules = [
82
- Rule.from_dict(rule_dict) for rule_dict in rule_collection_dict["rules"]
83
- ]
84
- num_questions = rule_collection_dict["num_questions"]
85
- new_rc = cls(rules=rules)
86
- new_rc.num_questions = num_questions
87
- return new_rc
88
-
89
- def add_rule(self, rule: Rule) -> None:
90
- """Add a rule to a survey.
91
-
92
- >>> rule_collection = RuleCollection()
93
- >>> rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}))
94
- >>> len(rule_collection)
95
- 1
96
-
97
- >>> rule_collection = RuleCollection()
98
- >>> r = Rule(current_q=1, expression="True", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule = True)
99
- >>> rule_collection.add_rule(r)
100
- >>> rule_collection[0] == r
101
- True
102
- >>> len(rule_collection.applicable_rules(1, before_rule=True))
103
- 1
104
- >>> len(rule_collection.applicable_rules(1, before_rule=False))
105
- 0
106
- """
107
- self.append(rule)
108
-
109
- def show_rules(self) -> None:
110
- """Print the rules in a table.
111
-
112
-
113
- .. code-block:: python
114
-
115
- rule_collection = RuleCollection.example()
116
- rule_collection.show_rules()
117
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
118
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
119
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
120
- 1 │ q1 == 'yes' 3 │ 1 │ False │
121
- │ 1 │ q1 == 'no' │ 2 │ 1 │ False │
122
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
123
- """
124
- return self.to_dataset()
125
-
126
- def skip_question_before_running(self, q_now: int, answers: dict[str, Any]) -> bool:
127
- """Determine if a question should be skipped before running the question.
128
-
129
- :param q_now: The current question index.
130
- :param answers: The answers to the survey questions.
131
-
132
- >>> rule_collection = RuleCollection()
133
- >>> r = Rule(current_q=1, expression="True", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
134
- >>> rule_collection.add_rule(r)
135
- >>> rule_collection.skip_question_before_running(1, {})
136
- True
137
-
138
- >>> rule_collection = RuleCollection()
139
- >>> r = Rule(current_q=1, expression="False", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
140
- >>> rule_collection.add_rule(r)
141
- >>> rule_collection.skip_question_before_running(1, {})
142
- False
143
-
144
- """
145
- for rule in self.applicable_rules(q_now, before_rule=True):
146
- if rule.evaluate(answers):
147
- return True
148
- return False
149
-
150
- def applicable_rules(self, q_now: int, before_rule: bool = False) -> list:
151
- """Show the rules that apply at the current node.
152
-
153
- :param q_now: The current question index.
154
- :param before_rule: If True, return rules that are of the type that apply before the question is asked.
155
-
156
- Example usage:
157
-
158
- >>> rule_collection = RuleCollection.example()
159
- >>> rule_collection.applicable_rules(1)
160
- [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False)]
161
-
162
- The default is that the rule is applied after the question is asked.
163
- If we want to see the rules that apply before the question is asked, we can set before_rule=True.
164
-
165
- .. code-block:: python
166
-
167
- rule_collection = RuleCollection.example()
168
- rule_collection.applicable_rules(1)
169
- [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4})]
170
-
171
- More than one rule can apply. For example, suppose we are at node 1.
172
- We could have three rules:
173
- 1. "q1 == 'a' ==> 3
174
- 2. "q1 == 'b' ==> 4
175
- 3. "q1 == 'c' ==> 5
176
- """
177
- return [
178
- rule
179
- for rule in self
180
- if rule.current_q == q_now and rule.before_rule == before_rule
181
- ]
182
-
183
- def next_question(self, q_now: int, answers: dict[str, Any]) -> NextQuestion:
184
- """Find the next question by index, given the rule collection.
185
-
186
- This rule is applied after the question is answered.
187
-
188
- :param q_now: The current question index.
189
- :param answers: The answers to the survey questions so far, including the current question.
190
-
191
- >>> rule_collection = RuleCollection.example()
192
- >>> rule_collection.next_question(1, {'q1': 'yes'})
193
- NextQuestion(next_q=3, num_rules_found=2, expressions_evaluating_to_true=1, priority=1)
194
-
195
- """
196
- expressions_evaluating_to_true = 0
197
- next_q = None
198
- highest_priority = -2 # start with -2 to 'pick up' the default rule added
199
- num_rules_found = 0
200
-
201
- for rule in self.applicable_rules(q_now, before_rule=False):
202
- num_rules_found += 1
203
- try:
204
- if rule.evaluate(answers): # evaluates to True
205
- expressions_evaluating_to_true += 1
206
- if rule.priority > highest_priority: # higher priority
207
- # we have a new champ!
208
- next_q, highest_priority = rule.next_q, rule.priority
209
- except SurveyRuleCannotEvaluateError:
210
- raise
211
-
212
- if num_rules_found == 0:
213
- raise SurveyRuleCollectionHasNoRulesAtNodeError(
214
- f"No rules found for question {q_now}"
215
- )
216
-
217
- ## Now we need to check if the *next question* has any 'before; rules that we should follow
218
- for rule in self.applicable_rules(next_q, before_rule=True):
219
- if rule.evaluate(answers): # rule evaluates to True
220
- return self.next_question(next_q, answers)
221
-
222
- return NextQuestion(
223
- next_q, num_rules_found, expressions_evaluating_to_true, highest_priority
224
- )
225
-
226
- @property
227
- def non_default_rules(self) -> List[Rule]:
228
- """Return all rules that are not the default rule.
229
-
230
- >>> rule_collection = RuleCollection.example()
231
- >>> len(rule_collection.non_default_rules)
232
- 2
233
-
234
- Example usage:
235
-
236
- .. code-block:: python
237
-
238
- rule_collection = RuleCollection.example()
239
- len(rule_collection.non_default_rules)
240
- 2
241
-
242
- """
243
- return [rule for rule in self if rule.priority > -1]
244
-
245
- def keys_between(self, start_q, end_q, right_inclusive=True):
246
- """Return a list of all question indices between start_q and end_q.
247
-
248
- Example usage:
249
-
250
- .. code-block:: python
251
-
252
- rule_collection = RuleCollection(num_questions=5)
253
- rule_collection.keys_between(1, 3)
254
- [2, 3]
255
- rule_collection.keys_between(1, 4)
256
- [2, 3, 4]
257
- rule_collection.keys_between(1, EndOfSurvey, right_inclusive=False)
258
- [2, 3]
259
-
260
- """
261
- # If it's the end of the survey, all questions between the start_q and the end of the survey now depend on the start_q
262
- if end_q == EndOfSurvey:
263
- if self.num_questions is None:
264
- raise ValueError(
265
- "Cannot determine DAG when EndOfSurvey and when num_questions is not known."
266
- )
267
- end_q = self.num_questions - 1
268
-
269
- question_range = list(range(start_q + 1, end_q + int(right_inclusive)))
270
-
271
- return question_range
272
-
273
- @property
274
- def dag(self) -> dict:
275
- """
276
- Find the DAG of the survey, based on the skip logic.
277
-
278
- Keys are children questions; the list of values are nodes that must be answered first
279
-
280
- Rules are designated at the current question and then direct where
281
- control goes next. As such, the destination nodes are the keys
282
- and the current nodes are the values. Furthermore, all questions between
283
- the current and destination nodes are also included as keys, as they will depend
284
- on the answer to the focal node as well.
285
-
286
- For exmaple, if we have a rule that says "if q1 == 'yes', go to q3", then q3 depends on q1, but so does q2.
287
- So the DAG would be {3: [1], 2: [1]}.
288
-
289
- Example usage:
290
-
291
- .. code-block:: python
292
-
293
- rule_collection = RuleCollection(num_questions=5)
294
- qn2i = {'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}
295
- rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index = qn2i))
296
- rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index = qn2i))
297
- rule_collection.dag
298
- {2: {1}, 3: {1}}
299
-
300
- """
301
- children_to_parents = defaultdict(set)
302
- # We are only interested in non-default rules. Default rules are those
303
- # that just go to the next question, so they don't add any dependencies
304
-
305
- ## I think for a skip-question, the potenially-skippable question
306
- ## depends on all the other questions bein answered first.
307
- for rule in self.non_default_rules:
308
- if not rule.before_rule:
309
- # for a regular rule, the next question depends on the current question answer
310
- current_q, next_q = rule.current_q, rule.next_q
311
- for q in self.keys_between(current_q, next_q):
312
- children_to_parents[q].add(current_q)
313
- else:
314
- # for the 'before rule' skipping depends on all previous answers.
315
- focal_q = rule.current_q
316
- for q in range(0, focal_q):
317
- children_to_parents[focal_q].add(q)
318
-
319
- return DAG(dict(sorted(children_to_parents.items())))
320
-
321
- def detect_cycles(self):
322
- """
323
- Detect cycles in the survey rules using depth-first search.
324
-
325
- :return: A list of cycles if any are found, otherwise an empty list.
326
- """
327
- dag = self.dag
328
- visited = set()
329
- path = []
330
- cycles = []
331
-
332
- def dfs(node):
333
- if node in path:
334
- cycle = path[path.index(node) :]
335
- cycles.append(cycle + [node])
336
- return
337
-
338
- if node in visited:
339
- return
340
-
341
- visited.add(node)
342
- path.append(node)
343
-
344
- for child in dag.get(node, []):
345
- dfs(child)
346
-
347
- path.pop()
348
-
349
- for node in dag:
350
- if node not in visited:
351
- dfs(node)
352
-
353
- return cycles
354
-
355
- @classmethod
356
- def example(cls):
357
- """Create an example RuleCollection object."""
358
- qn2i = {"q1": 1, "q2": 2, "q3": 3, "q4": 4}
359
- return cls(
360
- num_questions=5,
361
- rules=[
362
- Rule(
363
- current_q=1,
364
- expression="q1 == 'yes'",
365
- next_q=3,
366
- priority=1,
367
- question_name_to_index=qn2i,
368
- ),
369
- Rule(
370
- current_q=1,
371
- expression="q1 == 'no'",
372
- next_q=2,
373
- priority=1,
374
- question_name_to_index=qn2i,
375
- ),
376
- ],
377
- )
378
-
379
-
380
- if __name__ == "__main__":
381
- import doctest
382
-
383
- doctest.testmod(optionflags=doctest.ELLIPSIS)
384
-
385
- print(RuleCollection.example()._repr_html_())
1
+ """A collection of rules for a survey."""
2
+
3
+ from typing import List, Union, Any, Optional
4
+ from collections import defaultdict, UserList
5
+
6
+ from edsl.exceptions import (
7
+ SurveyRuleCannotEvaluateError,
8
+ SurveyRuleCollectionHasNoRulesAtNodeError,
9
+ )
10
+ from edsl.utilities.interface import print_table_with_rich
11
+ from edsl.surveys.Rule import Rule
12
+ from edsl.surveys.base import EndOfSurvey
13
+ from edsl.surveys.DAG import DAG
14
+
15
+ # from graphlib import TopologicalSorter
16
+
17
+ from collections import namedtuple
18
+
19
+ NextQuestion = namedtuple(
20
+ "NextQuestion", "next_q, num_rules_found, expressions_evaluating_to_true, priority"
21
+ )
22
+
23
+
24
+ class RuleCollection(UserList):
25
+ """A collection of rules for a particular survey."""
26
+
27
+ def __init__(self, num_questions: Optional[int] = None, rules: List[Rule] = None):
28
+ """Initialize the RuleCollection object.
29
+
30
+ :param num_questions: The number of questions in the survey.
31
+ :param rules: A list of Rule objects.
32
+ """
33
+ super().__init__(rules or [])
34
+ self.num_questions = num_questions
35
+
36
+ def __repr__(self):
37
+ """Return a string representation of the RuleCollection object.
38
+
39
+ Example usage:
40
+
41
+ .. code-block:: python
42
+
43
+ rule_collection = RuleCollection.example()
44
+ _ = eval(repr(rule_collection))
45
+
46
+ """
47
+ return f"RuleCollection(rules={self.data}, num_questions={self.num_questions})"
48
+
49
+ def to_dict(self, add_edsl_version=True):
50
+ """Create a dictionary representation of the RuleCollection object."""
51
+ return {
52
+ "rules": [rule.to_dict() for rule in self],
53
+ "num_questions": self.num_questions,
54
+ }
55
+
56
+ @classmethod
57
+ def from_dict(cls, rule_collection_dict):
58
+ """Create a RuleCollection object from a dictionary.
59
+
60
+ >>> rule_collection = RuleCollection.example()
61
+ >>> rule_collection_dict = rule_collection.to_dict()
62
+ >>> new_rule_collection = RuleCollection.from_dict(rule_collection_dict)
63
+ >>> repr(new_rule_collection) == repr(rule_collection)
64
+ True
65
+ """
66
+ rules = [
67
+ Rule.from_dict(rule_dict) for rule_dict in rule_collection_dict["rules"]
68
+ ]
69
+ num_questions = rule_collection_dict["num_questions"]
70
+ new_rc = cls(rules=rules)
71
+ new_rc.num_questions = num_questions
72
+ return new_rc
73
+
74
+ def add_rule(self, rule: Rule) -> None:
75
+ """Add a rule to a survey.
76
+
77
+ >>> rule_collection = RuleCollection()
78
+ >>> rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}))
79
+ >>> len(rule_collection)
80
+ 1
81
+
82
+ >>> rule_collection = RuleCollection()
83
+ >>> r = Rule(current_q=1, expression="True", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule = True)
84
+ >>> rule_collection.add_rule(r)
85
+ >>> rule_collection[0] == r
86
+ True
87
+ >>> len(rule_collection.applicable_rules(1, before_rule=True))
88
+ 1
89
+ >>> len(rule_collection.applicable_rules(1, before_rule=False))
90
+ 0
91
+ """
92
+ self.append(rule)
93
+
94
+ def show_rules(self) -> None:
95
+ """Print the rules in a table.
96
+
97
+
98
+ .. code-block:: python
99
+
100
+ rule_collection = RuleCollection.example()
101
+ rule_collection.show_rules()
102
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
103
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
104
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
105
+ │ 1 │ q1 == 'yes' │ 3 │ 1 │ False │
106
+ │ 1 │ q1 == 'no' │ 2 │ 1 │ False │
107
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
108
+ """
109
+ keys = ["current_q", "expression", "next_q", "priority", "before_rule"]
110
+ rule_list = []
111
+ for rule in sorted(self, key=lambda r: r.current_q):
112
+ rule_list.append({k: getattr(rule, k) for k in keys})
113
+
114
+ print_table_with_rich(rule_list)
115
+
116
+ def skip_question_before_running(self, q_now: int, answers: dict[str, Any]) -> bool:
117
+ """Determine if a question should be skipped before running the question.
118
+
119
+ :param q_now: The current question index.
120
+ :param answers: The answers to the survey questions.
121
+
122
+ >>> rule_collection = RuleCollection()
123
+ >>> r = Rule(current_q=1, expression="True", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
124
+ >>> rule_collection.add_rule(r)
125
+ >>> rule_collection.skip_question_before_running(1, {})
126
+ True
127
+
128
+ >>> rule_collection = RuleCollection()
129
+ >>> r = Rule(current_q=1, expression="False", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
130
+ >>> rule_collection.add_rule(r)
131
+ >>> rule_collection.skip_question_before_running(1, {})
132
+ False
133
+
134
+ """
135
+ for rule in self.applicable_rules(q_now, before_rule=True):
136
+ if rule.evaluate(answers):
137
+ return True
138
+ return False
139
+
140
+ def applicable_rules(self, q_now: int, before_rule: bool = False) -> list:
141
+ """Show the rules that apply at the current node.
142
+
143
+ :param q_now: The current question index.
144
+ :param before_rule: If True, return rules that are of the type that apply before the question is asked.
145
+
146
+ Example usage:
147
+
148
+ >>> rule_collection = RuleCollection.example()
149
+ >>> rule_collection.applicable_rules(1)
150
+ [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False)]
151
+
152
+ The default is that the rule is applied after the question is asked.
153
+ If we want to see the rules that apply before the question is asked, we can set before_rule=True.
154
+
155
+ .. code-block:: python
156
+
157
+ rule_collection = RuleCollection.example()
158
+ rule_collection.applicable_rules(1)
159
+ [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4})]
160
+
161
+ More than one rule can apply. For example, suppose we are at node 1.
162
+ We could have three rules:
163
+ 1. "q1 == 'a' ==> 3
164
+ 2. "q1 == 'b' ==> 4
165
+ 3. "q1 == 'c' ==> 5
166
+ """
167
+ return [
168
+ rule
169
+ for rule in self
170
+ if rule.current_q == q_now and rule.before_rule == before_rule
171
+ ]
172
+
173
+ def next_question(self, q_now: int, answers: dict[str, Any]) -> NextQuestion:
174
+ """Find the next question by index, given the rule collection.
175
+
176
+ This rule is applied after the question is answered.
177
+
178
+ :param q_now: The current question index.
179
+ :param answers: The answers to the survey questions so far, including the current question.
180
+
181
+ >>> rule_collection = RuleCollection.example()
182
+ >>> rule_collection.next_question(1, {'q1': 'yes'})
183
+ NextQuestion(next_q=3, num_rules_found=2, expressions_evaluating_to_true=1, priority=1)
184
+
185
+ """
186
+ # # is this the first question? If it is, we need to check if it should be skipped.
187
+ # if q_now == 0:
188
+ # if self.skip_question_before_running(q_now, answers):
189
+ # return NextQuestion(
190
+ # next_q=q_now + 1,
191
+ # num_rules_found=0,
192
+ # expressions_evaluating_to_true=0,
193
+ # priority=-1,
194
+ # )
195
+
196
+ # breakpoint()
197
+ expressions_evaluating_to_true = 0
198
+ next_q = None
199
+ highest_priority = -2 # start with -2 to 'pick up' the default rule added
200
+ num_rules_found = 0
201
+
202
+ for rule in self.applicable_rules(q_now, before_rule=False):
203
+ num_rules_found += 1
204
+ try:
205
+ if rule.evaluate(answers): # evaluates to True
206
+ expressions_evaluating_to_true += 1
207
+ if rule.priority > highest_priority: # higher priority
208
+ # we have a new champ!
209
+ next_q, highest_priority = rule.next_q, rule.priority
210
+ except SurveyRuleCannotEvaluateError:
211
+ raise
212
+
213
+ if num_rules_found == 0:
214
+ raise SurveyRuleCollectionHasNoRulesAtNodeError(
215
+ f"No rules found for question {q_now}"
216
+ )
217
+
218
+ # breakpoint()
219
+ ## Now we need to check if the *next question* has any 'before; rules that we should follow
220
+ for rule in self.applicable_rules(next_q, before_rule=True):
221
+ if rule.evaluate(answers): # rule evaluates to True
222
+ return self.next_question(next_q, answers)
223
+
224
+ return NextQuestion(
225
+ next_q, num_rules_found, expressions_evaluating_to_true, highest_priority
226
+ )
227
+
228
+ @property
229
+ def non_default_rules(self) -> List[Rule]:
230
+ """Return all rules that are not the default rule.
231
+
232
+ >>> rule_collection = RuleCollection.example()
233
+ >>> len(rule_collection.non_default_rules)
234
+ 2
235
+
236
+ Example usage:
237
+
238
+ .. code-block:: python
239
+
240
+ rule_collection = RuleCollection.example()
241
+ len(rule_collection.non_default_rules)
242
+ 2
243
+
244
+ """
245
+ return [rule for rule in self if rule.priority > -1]
246
+
247
+ def keys_between(self, start_q, end_q, right_inclusive=True):
248
+ """Return a list of all question indices between start_q and end_q.
249
+
250
+ Example usage:
251
+
252
+ .. code-block:: python
253
+
254
+ rule_collection = RuleCollection(num_questions=5)
255
+ rule_collection.keys_between(1, 3)
256
+ [2, 3]
257
+ rule_collection.keys_between(1, 4)
258
+ [2, 3, 4]
259
+ rule_collection.keys_between(1, EndOfSurvey, right_inclusive=False)
260
+ [2, 3]
261
+
262
+ """
263
+ # If it's the end of the survey, all questions between the start_q and the end of the survey
264
+ # now depend on the start_q
265
+ if end_q == EndOfSurvey:
266
+ if self.num_questions is None:
267
+ raise ValueError(
268
+ "Cannot determine DAG when EndOfSurvey and when num_questions is not known."
269
+ )
270
+ end_q = self.num_questions - 1
271
+
272
+ question_range = list(range(start_q + 1, end_q + int(right_inclusive)))
273
+
274
+ return question_range
275
+
276
+ @property
277
+ def dag(self) -> dict:
278
+ """
279
+ Find the DAG of the survey, based on the skip logic.
280
+
281
+ Keys are children questions; the list of values are nodes that must be answered first
282
+
283
+ Rules are designated at the current question and then direct where
284
+ control goes next. As such, the destination nodes are the keys
285
+ and the current nodes are the values. Furthermore, all questions between
286
+ the current and destination nodes are also included as keys, as they will depend
287
+ on the answer to the focal node as well.
288
+
289
+ For exmaple, if we have a rule that says "if q1 == 'yes', go to q3", then q3 depends on q1, but so does q2.
290
+ So the DAG would be {3: [1], 2: [1]}.
291
+
292
+ Example usage:
293
+
294
+ .. code-block:: python
295
+
296
+ rule_collection = RuleCollection(num_questions=5)
297
+ qn2i = {'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}
298
+ rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index = qn2i))
299
+ rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index = qn2i))
300
+ rule_collection.dag
301
+ {2: {1}, 3: {1}}
302
+
303
+ """
304
+ children_to_parents = defaultdict(set)
305
+ # We are only interested in non-default rules. Default rules are those
306
+ # that just go to the next question, so they don't add any dependencies
307
+
308
+ ## I think for a skip-question, the potenially-skippable question
309
+ ## depends on all the other questions bein answered first.
310
+ for rule in self.non_default_rules:
311
+ if not rule.before_rule:
312
+ # for a regular rule, the next question depends on the current question answer
313
+ current_q, next_q = rule.current_q, rule.next_q
314
+ for q in self.keys_between(current_q, next_q):
315
+ children_to_parents[q].add(current_q)
316
+ else:
317
+ # for the 'before rule' skipping depends on all previous answers.
318
+ focal_q = rule.current_q
319
+ for q in range(0, focal_q):
320
+ children_to_parents[focal_q].add(q)
321
+
322
+ return DAG(dict(sorted(children_to_parents.items())))
323
+
324
+ def detect_cycles(self):
325
+ """
326
+ Detect cycles in the survey rules using depth-first search.
327
+
328
+ :return: A list of cycles if any are found, otherwise an empty list.
329
+ """
330
+ dag = self.dag
331
+ visited = set()
332
+ path = []
333
+ cycles = []
334
+
335
+ def dfs(node):
336
+ if node in path:
337
+ cycle = path[path.index(node) :]
338
+ cycles.append(cycle + [node])
339
+ return
340
+
341
+ if node in visited:
342
+ return
343
+
344
+ visited.add(node)
345
+ path.append(node)
346
+
347
+ for child in dag.get(node, []):
348
+ dfs(child)
349
+
350
+ path.pop()
351
+
352
+ for node in dag:
353
+ if node not in visited:
354
+ dfs(node)
355
+
356
+ return cycles
357
+
358
+ @classmethod
359
+ def example(cls):
360
+ """Create an example RuleCollection object."""
361
+ qn2i = {"q1": 1, "q2": 2, "q3": 3, "q4": 4}
362
+ return cls(
363
+ num_questions=5,
364
+ rules=[
365
+ Rule(
366
+ current_q=1,
367
+ expression="q1 == 'yes'",
368
+ next_q=3,
369
+ priority=1,
370
+ question_name_to_index=qn2i,
371
+ ),
372
+ Rule(
373
+ current_q=1,
374
+ expression="q1 == 'no'",
375
+ next_q=2,
376
+ priority=1,
377
+ question_name_to_index=qn2i,
378
+ ),
379
+ ],
380
+ )
381
+
382
+
383
+ if __name__ == "__main__":
384
+ # pass
385
+ import doctest
386
+
387
+ doctest.testmod(optionflags=doctest.ELLIPSIS)