diff --git a/NOTES.md b/NOTES.md index ed990a37..b3fb71c1 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,17 +1,6 @@ # NOTES -## Observability - -> Priority: must have - -Requirements and solution - to be analyzed - - - How to track regular vs streamed responses? Streamed responses are unreadable / meaningless individually. Higher abstraction layer is needed to handle them - eg. "folder" with individual chunks of data. Completion ID allows to track incoming chunks under a single context. - - Completion, if streamed, needs extra info on whether it has been completed or disrupted for any reason. - - - ## Better control over deserialization > Priority: must have @@ -28,6 +17,8 @@ Custom deserialization strategy is also needed for partial updates, maybe for st ## Validation +> Priority: must have + ### Returning errors - array vs typed object Array is simple and straightforward, but it's not type safe and does not provide a way to add custom methods to the error object. @@ -271,7 +262,7 @@ Identify capabilities of the engine that could be parallelized, so we can speed ### Simple example ```cli -iphp --messages "Jason is 35 years old" --respond-with UserDetails --response-format yaml +instruct --messages "Jason is 35 years old" --respond-with UserDetails --response-format yaml ``` It will search for UserFormat.php (PHP class) or UserFormat.json (JSONSchema) in current dir. We should be able to provide a path to class code / schema definitions directory. @@ -280,7 +271,7 @@ Default response format is JSON, we can render it to YAML (or other supported fo ### Scalar example ```cli -iphp --messages "Jason is 35 years old" --respond-with Scalar::bool('isAdult') +instruct --messages "Jason is 35 years old" --respond-with Scalar::bool('isAdult') ``` @@ -376,3 +367,29 @@ Is it enough? ### Solution Validation can be also customized by implementing CanSelfValidate interface. It allows you to fully control how the data is validated. At the moment it skips built in Symfony Validator logic, so you have to deal with Symfony validation constraints manually. + + + +## Observability + +### Problem and ideas + +> Priority: must have + +Requirements and solution - to be analyzed + +- How to track regular vs streamed responses? Streamed responses are unreadable / meaningless individually. Higher abstraction layer is needed to handle them - eg. "folder" with individual chunks of data. Completion ID allows to track incoming chunks under a single context. +- Completion, if streamed, needs extra info on whether it has been completed or disrupted for any reason. + +### Solution + +You can: +- wiretap() to get stream of all internal events +- connect to specific events via onEvent() + +This allows you plug in your preferred logging / monitoring system. + +- Performance - timestamps are available on events, which allows you to record performance of either full flow or individual steps. +- Errors - can be done via onError() +- Validation errors - can be done via onEvent() +- Generated data models - can be done via onEvent() diff --git a/config/autowire.php b/config/autowire.php index 7946d90d..59002ccd 100644 --- a/config/autowire.php +++ b/config/autowire.php @@ -1,6 +1,7 @@ respond( diff --git a/src/Utils/ComponentConfig.php b/src/Configuration/ComponentConfig.php similarity index 94% rename from src/Utils/ComponentConfig.php rename to src/Configuration/ComponentConfig.php index fe5206c5..54d4b1b4 100644 --- a/src/Utils/ComponentConfig.php +++ b/src/Configuration/ComponentConfig.php @@ -1,6 +1,6 @@ eventDispatcher->dispatch(new FunctionCallResponseReceived($response)); $json = $response->toolCalls[0]->functionArguments; - [$object, $errors] = $this->responseHandler->toResponse($responseModel, $json); - if (empty($errors)) { - $this->eventDispatcher->dispatch(new FunctionCallResponseConvertedToObject($object)); - if ($object instanceof CanTransformResponse) { - $result = $object->transform(); - $this->eventDispatcher->dispatch(new FunctionCallResponseTransformed($result)); - } else { - $result = $object; + try { + $result = $this->responseHandler->toResponse($responseModel, $json); + if ($result->isSuccess()) { + $object = $result->value(); + $this->eventDispatcher->dispatch(new FunctionCallResponseConvertedToObject($object)); + return $object; } - $this->eventDispatcher->dispatch(new FunctionCallResultReady($result)); - return $result; + $errors = $result->errorValue(); + } catch (ValidationException $e) { + $errors = [$e->getMessage()]; + } catch (DeserializationException $e) { + $errors = [$e->getMessage()]; + } catch (Exception $e) { + $this->eventDispatcher->dispatch(new ResponseGenerationFailed($request, $e->getMessage())); + throw $e; } $messages[] = ['role' => 'assistant', 'content' => $json]; - $messages[] = ['role' => 'user', 'content' => $this->retryPrompt . '\n' . $errors]; + $messages[] = ['role' => 'user', 'content' => $this->retryPrompt . ': ' . implode(", ", $errors)]; $retries++; if ($retries <= $request->maxRetries) { $this->eventDispatcher->dispatch(new NewValidationRecoveryAttempt($retries, $errors)); } } $this->eventDispatcher->dispatch(new ValidationRecoveryLimitReached($retries, $errors)); - throw new Exception("Failed to extract data due to validation constraints: " . $errors); + throw new Exception("Failed to extract data due to validation errors: " . implode(", ", $errors)); } } \ No newline at end of file diff --git a/src/Core/ResponseHandler.php b/src/Core/ResponseHandler.php index 5a4c67e5..6af50477 100644 --- a/src/Core/ResponseHandler.php +++ b/src/Core/ResponseHandler.php @@ -5,16 +5,18 @@ use Cognesy\Instructor\Contracts\CanDeserializeJson; use Cognesy\Instructor\Contracts\CanDeserializeResponse; use Cognesy\Instructor\Contracts\CanSelfValidate; +use Cognesy\Instructor\Contracts\CanTransformResponse; use Cognesy\Instructor\Contracts\CanValidateResponse; -use Cognesy\Instructor\Events\RequestHandler\ValidationRecoveryLimitReached; use Cognesy\Instructor\Events\ResponseHandler\CustomResponseDeserializationAttempt; use Cognesy\Instructor\Events\ResponseHandler\CustomResponseValidationAttempt; use Cognesy\Instructor\Events\ResponseHandler\ResponseDeserializationAttempt; use Cognesy\Instructor\Events\ResponseHandler\ResponseDeserializationFailed; use Cognesy\Instructor\Events\ResponseHandler\ResponseDeserialized; +use Cognesy\Instructor\Events\ResponseHandler\ResponseTransformed; use Cognesy\Instructor\Events\ResponseHandler\ResponseValidated; use Cognesy\Instructor\Events\ResponseHandler\ResponseValidationAttempt; use Cognesy\Instructor\Events\ResponseHandler\ResponseValidationFailed; +use Cognesy\Instructor\Utils\Result; class ResponseHandler { @@ -36,55 +38,73 @@ public function __construct( /** * Deserialize JSON and validate response object */ - public function toResponse(ResponseModel $responseModel, string $json) : array { - try { - $object = $this->deserialize($responseModel, $json); - } catch (\Exception $e) { - $this->eventDispatcher->dispatch(new ResponseDeserializationFailed($e->getMessage())); - return [null, $e->getMessage()]; + public function toResponse(ResponseModel $responseModel, string $json) : Result { + // ...deserialize + $deserializationResult = $this->deserialize($responseModel, $json); + if ($deserializationResult->isFailure()) { + $this->eventDispatcher->dispatch(new ResponseDeserializationFailed($deserializationResult->errorMessage())); + return $deserializationResult; } + $object = $deserializationResult->value(); $this->eventDispatcher->dispatch(new ResponseDeserialized($object)); - if ($this->validate($object)) { - $this->eventDispatcher->dispatch(new ResponseValidated($object)); - return [$object, null]; + + // ...validate + $validationResult = $this->validate($object); + if ($validationResult->isFailure()) { + $this->eventDispatcher->dispatch(new ResponseValidationFailed($validationResult->errorValue())); + return $validationResult; } - $this->eventDispatcher->dispatch(new ResponseValidationFailed($this->errors())); - return [null, $this->errors()]; + $this->eventDispatcher->dispatch(new ResponseValidated($object)); + + // ...transform + $transformedObject = $this->transform($object); + + return Result::success($transformedObject); } /** * Deserialize response JSON */ - protected function deserialize(ResponseModel $responseModel, string $json) : mixed { + protected function deserialize(ResponseModel $responseModel, string $json) : Result { if ($responseModel->instance instanceof CanDeserializeJson) { - $this->eventDispatcher->dispatch(new CustomResponseDeserializationAttempt( - $responseModel->instance, - $json - )); - return $responseModel->instance->fromJson($json); + $this->eventDispatcher->dispatch(new CustomResponseDeserializationAttempt($responseModel->instance, $json)); + return Result::try(fn() => $responseModel->instance->fromJson($json)); } // else - use standard deserializer $this->eventDispatcher->dispatch(new ResponseDeserializationAttempt($responseModel, $json)); - return $this->deserializer->deserialize($json, $responseModel->class); + return Result::try(fn() => $this->deserializer->deserialize($json, $responseModel->class)); } /** * Validate deserialized response object */ - protected function validate(object $response) : bool { + protected function validate(object $response) : Result { if ($response instanceof CanSelfValidate) { $this->eventDispatcher->dispatch(new CustomResponseValidationAttempt($response)); - return $response->validate(); + $errors = $response->validate(); + return match(count($errors)) { + 0 => Result::success($response), + default => Result::failure($errors) + }; } // else - use standard validator $this->eventDispatcher->dispatch(new ResponseValidationAttempt($response)); - return $this->validator->validate($response); + $errors = $this->validator->validate($response); + return match(count($errors)) { + 0 => Result::success($response), + default => Result::failure($errors) + }; } /** - * Get validation errors + * Transform response object */ - public function errors() : string { - return $this->validator->errors(); + protected function transform(object $object) : mixed { + if ($object instanceof CanTransformResponse) { + $result = $object->transform(); + $this->eventDispatcher->dispatch(new ResponseTransformed($result)); + return $result; + } + return $object; } } \ No newline at end of file diff --git a/src/Deserializers/Symfony/Deserializer.php b/src/Deserializers/Symfony/Deserializer.php index c31920d3..36eb4815 100644 --- a/src/Deserializers/Symfony/Deserializer.php +++ b/src/Deserializers/Symfony/Deserializer.php @@ -2,6 +2,7 @@ namespace Cognesy\Instructor\Deserializers\Symfony; use Cognesy\Instructor\Contracts\CanDeserializeResponse; +use Cognesy\Instructor\Exceptions\DeserializationException; use Cognesy\Instructor\Validators\Symfony\BackedEnumNormalizer; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -36,6 +37,10 @@ public function deserialize(string $data, string $dataModelClass): object encoders: [new JsonEncoder()] ); - return $serializer->deserialize($data, $dataModelClass, 'json'); + try { + return $serializer->deserialize($data, $dataModelClass, 'json'); + } catch (\Exception $e) { + throw new DeserializationException($e->getMessage(), $dataModelClass, $data); + } } } diff --git a/src/Events/Instructor/InstructorReady.php b/src/Events/Instructor/InstructorReady.php index 3282fd8f..ce0e8c82 100644 --- a/src/Events/Instructor/InstructorReady.php +++ b/src/Events/Instructor/InstructorReady.php @@ -2,8 +2,8 @@ namespace Cognesy\Instructor\Events\Instructor; +use Cognesy\Instructor\Configuration\Configuration; use Cognesy\Instructor\Events\Event; -use Cognesy\Instructor\Utils\Configuration; class InstructorReady extends Event { diff --git a/src/Events/RequestHandler/NewValidationRecoveryAttempt.php b/src/Events/RequestHandler/NewValidationRecoveryAttempt.php index eb62f463..b49c4685 100644 --- a/src/Events/RequestHandler/NewValidationRecoveryAttempt.php +++ b/src/Events/RequestHandler/NewValidationRecoveryAttempt.php @@ -7,7 +7,7 @@ class NewValidationRecoveryAttempt extends Event { public function __construct( public int $retry, - public string $errors, + public array $errors, ) { parent::__construct(); diff --git a/src/Events/RequestHandler/ResponseGenerationFailed.php b/src/Events/RequestHandler/ResponseGenerationFailed.php new file mode 100644 index 00000000..8121e382 --- /dev/null +++ b/src/Events/RequestHandler/ResponseGenerationFailed.php @@ -0,0 +1,21 @@ +error; + } +} \ No newline at end of file diff --git a/src/Events/RequestHandler/ValidationRecoveryLimitReached.php b/src/Events/RequestHandler/ValidationRecoveryLimitReached.php index 35a5820b..260f2636 100644 --- a/src/Events/RequestHandler/ValidationRecoveryLimitReached.php +++ b/src/Events/RequestHandler/ValidationRecoveryLimitReached.php @@ -8,7 +8,7 @@ class ValidationRecoveryLimitReached extends Event { public function __construct( public int $retries, - public string $errors, + public array $errors, ) { parent::__construct(); } diff --git a/src/Events/RequestHandler/FunctionCallResponseTransformed.php b/src/Events/ResponseHandler/ResponseTransformed.php similarity index 67% rename from src/Events/RequestHandler/FunctionCallResponseTransformed.php rename to src/Events/ResponseHandler/ResponseTransformed.php index 2a7cf3ae..366e1d8f 100644 --- a/src/Events/RequestHandler/FunctionCallResponseTransformed.php +++ b/src/Events/ResponseHandler/ResponseTransformed.php @@ -1,10 +1,10 @@ errors; + return json_encode($this->errors); } } \ No newline at end of file diff --git a/src/Exceptions/DeserializationException.php b/src/Exceptions/DeserializationException.php new file mode 100644 index 00000000..abfc2a59 --- /dev/null +++ b/src/Exceptions/DeserializationException.php @@ -0,0 +1,22 @@ + $this->message, + 'class' => $this->modelClass, + 'data' => $this->data, + ]); + } +} diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index 3cd885c8..2a20328c 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -5,7 +5,17 @@ class ValidationException extends Exception { - public function __construct(public $message) { + public function __construct( + public $message, + public array $errors + ) { parent::__construct($message); } + + public function __toString() : string { + return json_encode([ + 'message' => $this->message, + 'errors' => $this->errors + ]); + } } diff --git a/src/Extras/Scalars/Scalar.php b/src/Extras/Scalars/Scalar.php index 1f1967f5..e3c44ff4 100644 --- a/src/Extras/Scalars/Scalar.php +++ b/src/Extras/Scalars/Scalar.php @@ -4,14 +4,17 @@ use Cognesy\Instructor\Contracts\CanDeserializeJson; use Cognesy\Instructor\Contracts\CanProvideSchema; +use Cognesy\Instructor\Contracts\CanSelfValidate; use Cognesy\Instructor\Contracts\CanTransformResponse; +use Cognesy\Instructor\Exceptions\DeserializationException; +use Exception; use ReflectionEnum; /** * Scalar value adapter. * Improved DX via simplified retrieval of scalar value from LLM response. */ -class Scalar implements CanProvideSchema, CanDeserializeJson, CanTransformResponse +class Scalar implements CanProvideSchema, CanDeserializeJson, CanTransformResponse, CanSelfValidate { public mixed $value; @@ -72,33 +75,47 @@ public function toJsonSchema() : array { /** * Deserialize JSON into scalar value */ - public function fromJson(string $json) : self { - $array = json_decode($json, true); - $value = $array[$this->name] ?? $this->defaultValue; - if (($value === null) && $this->required) { - throw new \Exception("Value is required"); + public function fromJson(string $json) : static { + if (empty($json)) { + $this->value = $this->defaultValue; + return $this; } try { - $this->value = match ($this->type) { - ValueType::STRING => (string) $value, - ValueType::INTEGER => (int) $value, - ValueType::FLOAT => (float) $value, - ValueType::BOOLEAN => (bool) $value, - }; - } catch (\Throwable $e) { - throw new \Exception("Failed to deserialize value: " . $e->getMessage()); + // decode JSON into array + $array = json_decode($json, true); + } catch (Exception $e) { + throw new DeserializationException($e->getMessage(), $this->name, $json); + } + // check if value exists in JSON + $this->value = $array[$this->name] ?? $this->defaultValue; + return $this; + } + + /** + * Validate scalar value + */ + public function validate() : array { + $errors = []; + if ($this->required && $this->value === null) { + $errors[] = "Value '{$this->name}' is required"; } if (!empty($this->options) && !in_array($this->value, $this->options)) { - throw new \Exception("Value is not in the list of allowed options"); + $errors[] = "Value '{$this->name}' must be one of: " . implode(", ", $this->options); } - return $this; + return $errors; } /** * Transform response model into scalar value */ public function transform() : mixed { - return $this->value; + // try to match it to supported type + return match ($this->type) { + ValueType::STRING => (string) $this->value, + ValueType::INTEGER => (int) $this->value, + ValueType::FLOAT => (float) $this->value, + ValueType::BOOLEAN => (bool) $this->value, + }; } /** diff --git a/src/Instructor.php b/src/Instructor.php index 94d54f9c..afa04c17 100644 --- a/src/Instructor.php +++ b/src/Instructor.php @@ -1,6 +1,7 @@ */ +// function getNameLength(?string $name): Optional { +// return Optional::of($name) +// ->apply(fn($n) => trim($n)) +// ->apply(fn($n) => strlen($n)); +// } +// } +// +// $nameLength = $person->getNameLength(null)->getOrElse(0); +////////////////////////////////////////////////////////// + + /** + * @template T The type of the value in case of success. + */ +class Optional { + private mixed $value; + + /** + * @param T $value + */ + private function __construct(mixed $value) { + $this->value = $value; + } + + /** + * @param mixed $value + * @return self + */ + public static function of(mixed $value): self { + return new self($value); + } + + public function exists(): bool { + return $this->value !== null; + } + + /** + * @param T $default + * @return T + */ + public function getOrElse(mixed $default) : mixed { + return $this->exists() ? $this->value : $default; + } + + /** + * @param callable $f + * @return self + */ + public function apply(callable $f): self { + if (!$this->exists()) { + return self::of(null); + } + + return self::of($f($this->value)); + } +} + diff --git a/src/Utils/Result.php b/src/Utils/Result.php new file mode 100644 index 00000000..d1e7ed9d --- /dev/null +++ b/src/Utils/Result.php @@ -0,0 +1,180 @@ + Result object encapsulating either a success value or an error. +// */ +// function performOperation(): Result { +// // Some operation... +// if ($success) { +// return Result::success(42); // Assuming success with an integer value +// } else { +// return Result::failure("An error occurred"); // Assuming failure with a string error message +// } +// } +// +// $result = performOperation(); +// +// if ($result->isSuccess()) { +// // IDE should suggest `getValue` method and understand its return type is `int` +// $value = $result->getValue(); +// echo "Operation succeeded with result: $value"; +// } elseif ($result->isFailure()) { +// // IDE should suggest `getError` method and understand its return type is `string` +// $error = $result->getError(); +// echo "Operation failed with error: $error"; +// } +// +// // Transforming the result if operation succeeded +// $transformedResult = $result->try(function (int $value): string { +// return "Transformed value: " . ($value * 2); +// }); +// +// // Handling error, transforming it into a default value +// $defaultValue = $transformedResult->catch(function (string $error): string { +// return "Default value due to error: $error"; +// }); +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * @template T The type of the value in case of success. + * @template E The type of the error in case of failure. + */ +abstract class Result { + public static function success($value): Success { + return new Success($value); + } + + public static function failure($error): Failure { + return new Failure($error); + } + + abstract public function isSuccess(): bool; + abstract public function isFailure(): bool; + + /** + * @template S + * @param callable(T):S $f Function to apply to the value in case of success + * @return Result A new Result instance with the function applied, or the original failure + */ + public function apply(callable $f): Result { + if ($this->isSuccess()) { + try { + return self::success($f($this->value())); + } catch (Exception $e) { + return self::failure($e); + } + } + return $this; + } + + /** + * @template F + * @param callable(E):F $f Function to recover from the error in case of failure + * @return Result A new Result instance with the recovery applied, or the original success + */ + public function recover(callable $f): Result { + if ($this->isFailure()) { + try { + return self::success($f($this->getError())); + } catch (Exception $e) { + return self::failure($e); + } + } + return $this; + } + + /** + * @template R The return type of the callable. + * @param callable():R $callable The callable to execute. + * @return Result A Result instance representing the outcome of the callable execution. + */ + public static function try(callable $callable): Result { + try { + return self::success($callable()); + } catch (Exception $e) { + return self::failure($e); + } + } +} + +/** + * @template T + * @extends Result + */ +class Success extends Result { + /** + * @var T + */ + private $value; + + /** + * @param T $value The success value + */ + public function __construct($value) { + $this->value = $value; + } + + /** + * @return T + */ + public function value() { + return $this->value; + } + + public function isSuccess(): bool { + return true; + } + + public function isFailure(): bool { + return false; + } +} + +/** + * @template E + * @extends Result + */ +class Failure extends Result { + /** + * @var E + */ + private $error; + + /** + * @param E $error The error value + */ + public function __construct($error) { + $this->error = $error; + } + + /** + * @return E + */ + public function errorValue() : mixed { + return $this->error; + } + + public function errorMessage() : string { + if (is_subclass_of($this->error, Exception::class)) { + return $this->error->getMessage(); + } + return (string) $this->error; + } + + public function isSuccess(): bool { + return false; + } + + public function isFailure(): bool { + return true; + } +} diff --git a/src/Validators/Symfony/Validator.php b/src/Validators/Symfony/Validator.php index 14cae1ea..fb1e2992 100644 --- a/src/Validators/Symfony/Validator.php +++ b/src/Validators/Symfony/Validator.php @@ -1,5 +1,4 @@ addLoader(new AttributeLoader()) ->getValidator(); - $this->errors = $validator->validate($response); - return (count($this->errors) == 0); - } - - public function errors() : string { - $errors[] = "Invalid values found:"; - foreach ($this->errors as $error) { + $result = $validator->validate($response); + $errors = []; + foreach ($result as $error) { $path = $error->getPropertyPath(); $value = $error->getInvalidValue(); $message = $error->getMessage(); - $errors[] = " * parameter: {$path} = {$value} ({$message})"; + $errors[] = "Error in {$path} = {$value} ({$message})"; } - return implode("\n", $errors); + return $errors; } } \ No newline at end of file diff --git a/tests/Feature/ExtractionTest.php b/tests/Feature/ExtractionTest.php index 260aa376..4b3937b3 100644 --- a/tests/Feature/ExtractionTest.php +++ b/tests/Feature/ExtractionTest.php @@ -100,7 +100,7 @@ ->respond( messages: [['role' => 'user', 'content' => $text]], responseModel: ProjectEvents::class, - maxRetries: 2, + maxRetries: 0, ); expect($events)->toBeInstanceOf(ProjectEvents::class); diff --git a/tests/Feature/FunctionCallFactoryTest.php b/tests/Feature/FunctionCallFactoryTest.php index a5fc717e..cc4f1382 100644 --- a/tests/Feature/FunctionCallFactoryTest.php +++ b/tests/Feature/FunctionCallFactoryTest.php @@ -1,8 +1,8 @@ toBeInstanceOf(Person::class); expect($person->name)->toBe('Jason'); expect($person->age)->toBe(28); -})->skip("Not implemented yet"); +})->skip("Maybe adapter not implemented yet"); diff --git a/tests/Feature/PartialJsonTest.php b/tests/Feature/PartialJsonTest.php index da3ad9d9..5654bcea 100644 --- a/tests/Feature/PartialJsonTest.php +++ b/tests/Feature/PartialJsonTest.php @@ -82,4 +82,4 @@ function merge(array &$blueprint, array &$partial) break; } } -})->skip(); \ No newline at end of file +})->skip("Partials not implemented yet"); \ No newline at end of file diff --git a/tests/Feature/ResponseModelTest.php b/tests/Feature/ResponseModelTest.php index 3b5321e7..dbf3014e 100644 --- a/tests/Feature/ResponseModelTest.php +++ b/tests/Feature/ResponseModelTest.php @@ -1,8 +1,8 @@ name = 'Jason'; $person->age = 28; $validator = new Validator(); - expect($validator->validate($person))->toBe(true); + expect(count($validator->validate($person)))->toBe(0); }); @@ -19,7 +19,7 @@ $person->name = 'Jason'; $person->age = -28; $validator = new Validator(); - expect($validator->validate($person))->toBe(false); + expect(count($validator->validate($person)))->toBe(1); }); @@ -29,9 +29,9 @@ // age is less than 18 $person->age = 12; $validator = new Validator(); - expect($validator->validate($person))->toBe(false); + expect(count($validator->validate($person)))->toBe(1); // age is more or equal to 18 $person->age = 19; $validator = new Validator(); - expect($validator->validate($person))->toBe(true); + expect(count($validator->validate($person)))->toBe(0); });